metaowl 0.4.1 → 0.6.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 +50 -0
- package/README.md +267 -2
- 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 +144 -0
- package/build/runtime/modules/app-mounter.js +73 -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/constants.js +38 -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 +207 -0
- package/build/runtime/modules/fonts.js +172 -0
- package/build/runtime/modules/forms.js +193 -0
- package/build/runtime/modules/i18n.js +180 -0
- package/build/runtime/modules/image.js +175 -0
- package/build/runtime/modules/layouts.js +214 -0
- package/build/runtime/modules/link.js +141 -0
- package/build/runtime/modules/meta.js +117 -0
- package/build/runtime/modules/odoo-rpc.js +265 -0
- package/build/runtime/modules/pwa.js +272 -0
- package/build/runtime/modules/router.js +384 -0
- package/build/runtime/modules/seo.js +186 -0
- package/build/runtime/modules/store.js +198 -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 +197 -0
- package/eslint.js +29 -0
- package/package.json +45 -27
- 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,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,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module OdooRPC
|
|
3
|
+
*
|
|
4
|
+
* Odoo JSON-RPC Service for MetaOwl applications.
|
|
5
|
+
*/
|
|
6
|
+
import { MAGIC_STRINGS } from './constants.js';
|
|
7
|
+
let config = null;
|
|
8
|
+
let session = null;
|
|
9
|
+
let csrfToken = null;
|
|
10
|
+
const authListeners = [];
|
|
11
|
+
const SESSION_KEY = MAGIC_STRINGS.STORE_SESSION_KEY;
|
|
12
|
+
const CSRF_KEY = MAGIC_STRINGS.STORE_CSRF_KEY;
|
|
13
|
+
export function configure(nextConfig) {
|
|
14
|
+
config = {
|
|
15
|
+
persistSession: true,
|
|
16
|
+
baseUrl: '',
|
|
17
|
+
database: '',
|
|
18
|
+
...nextConfig
|
|
19
|
+
};
|
|
20
|
+
if (config.persistSession) {
|
|
21
|
+
restoreSession();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function getConfig() {
|
|
25
|
+
return config;
|
|
26
|
+
}
|
|
27
|
+
export function isConfigured() {
|
|
28
|
+
return config !== null && Boolean(config.baseUrl) && Boolean(config.database);
|
|
29
|
+
}
|
|
30
|
+
function restoreSession() {
|
|
31
|
+
try {
|
|
32
|
+
const sessionData = localStorage.getItem(SESSION_KEY);
|
|
33
|
+
const csrfData = localStorage.getItem(CSRF_KEY);
|
|
34
|
+
if (sessionData) {
|
|
35
|
+
session = JSON.parse(sessionData);
|
|
36
|
+
}
|
|
37
|
+
if (csrfData) {
|
|
38
|
+
csrfToken = csrfData;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Ignore storage errors
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function saveSession() {
|
|
46
|
+
if (!config?.persistSession)
|
|
47
|
+
return;
|
|
48
|
+
try {
|
|
49
|
+
if (session) {
|
|
50
|
+
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
localStorage.removeItem(SESSION_KEY);
|
|
54
|
+
}
|
|
55
|
+
if (csrfToken) {
|
|
56
|
+
localStorage.setItem(CSRF_KEY, csrfToken);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
localStorage.removeItem(CSRF_KEY);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Ignore storage errors
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function jsonRpc(service, method, args = []) {
|
|
67
|
+
if (!isConfigured() || !config) {
|
|
68
|
+
throw new Error('[metaowl] OdooService not configured. Call configure() first.');
|
|
69
|
+
}
|
|
70
|
+
const url = `${config.baseUrl}/jsonrpc`;
|
|
71
|
+
const payload = {
|
|
72
|
+
jsonrpc: '2.0',
|
|
73
|
+
method: 'call',
|
|
74
|
+
params: {
|
|
75
|
+
service,
|
|
76
|
+
method,
|
|
77
|
+
args
|
|
78
|
+
},
|
|
79
|
+
id: Math.floor(Math.random() * 1000000000)
|
|
80
|
+
};
|
|
81
|
+
const headers = {
|
|
82
|
+
'Content-Type': 'application/json'
|
|
83
|
+
};
|
|
84
|
+
if (csrfToken) {
|
|
85
|
+
headers['X-CSRF-Token'] = csrfToken;
|
|
86
|
+
}
|
|
87
|
+
const response = await fetch(url, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers,
|
|
90
|
+
body: JSON.stringify(payload),
|
|
91
|
+
credentials: 'include'
|
|
92
|
+
});
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new Error(`[metaowl] HTTP ${response.status}: ${response.statusText}`);
|
|
95
|
+
}
|
|
96
|
+
const data = await response.json();
|
|
97
|
+
if (data.error) {
|
|
98
|
+
const error = data.error;
|
|
99
|
+
throw new Error(`[metaowl] Odoo Error: ${error.message || error.data?.message || JSON.stringify(error)}`);
|
|
100
|
+
}
|
|
101
|
+
const setCookie = response.headers.get('set-cookie');
|
|
102
|
+
if (setCookie?.includes('csrf_token')) {
|
|
103
|
+
const match = setCookie.match(/csrf_token=([a-zA-Z0-9_-]+)/);
|
|
104
|
+
if (match) {
|
|
105
|
+
csrfToken = match[1] ?? null;
|
|
106
|
+
saveSession();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return data.result;
|
|
110
|
+
}
|
|
111
|
+
export async function authenticate(username, password) {
|
|
112
|
+
const user = username || config?.username;
|
|
113
|
+
const pass = password || config?.password || config?.apiKey;
|
|
114
|
+
if (!user || !pass || !config) {
|
|
115
|
+
throw new Error('[metaowl] Authentication requires username and password/apiKey');
|
|
116
|
+
}
|
|
117
|
+
const uid = await jsonRpc('common', 'authenticate', [
|
|
118
|
+
config.database,
|
|
119
|
+
user,
|
|
120
|
+
pass,
|
|
121
|
+
{}
|
|
122
|
+
]);
|
|
123
|
+
if (!uid) {
|
|
124
|
+
throw new Error('[metaowl] Authentication failed: invalid credentials');
|
|
125
|
+
}
|
|
126
|
+
session = {
|
|
127
|
+
uid,
|
|
128
|
+
username: user
|
|
129
|
+
};
|
|
130
|
+
try {
|
|
131
|
+
const userInfo = await searchRead('res.users', {
|
|
132
|
+
domain: [['id', '=', uid]],
|
|
133
|
+
fields: ['name', 'partner_id', 'lang', 'tz'],
|
|
134
|
+
limit: 1
|
|
135
|
+
});
|
|
136
|
+
if (userInfo.length > 0) {
|
|
137
|
+
const firstUser = userInfo[0];
|
|
138
|
+
session.name = typeof firstUser.name === 'string' ? firstUser.name : undefined;
|
|
139
|
+
session.partner_id = Array.isArray(firstUser.partner_id) ? Number(firstUser.partner_id[0]) : undefined;
|
|
140
|
+
session.lang = typeof firstUser.lang === 'string' ? firstUser.lang : undefined;
|
|
141
|
+
session.tz = typeof firstUser.tz === 'string' ? firstUser.tz : undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Ignore user info fetch errors
|
|
146
|
+
}
|
|
147
|
+
saveSession();
|
|
148
|
+
notifyAuthListeners();
|
|
149
|
+
return session;
|
|
150
|
+
}
|
|
151
|
+
export function isAuthenticated() {
|
|
152
|
+
return session !== null && session.uid !== null;
|
|
153
|
+
}
|
|
154
|
+
export function getSession() {
|
|
155
|
+
return session;
|
|
156
|
+
}
|
|
157
|
+
export function logout() {
|
|
158
|
+
session = null;
|
|
159
|
+
csrfToken = null;
|
|
160
|
+
saveSession();
|
|
161
|
+
notifyAuthListeners();
|
|
162
|
+
}
|
|
163
|
+
export async function searchRead(model, options = {}) {
|
|
164
|
+
const { domain = [], fields = [], limit = 80, offset = 0, order = '', context = {} } = options;
|
|
165
|
+
if (!isAuthenticated() || !config || !session) {
|
|
166
|
+
throw new Error('[metaowl] Not authenticated. Call authenticate() first.');
|
|
167
|
+
}
|
|
168
|
+
const args = [
|
|
169
|
+
config.database,
|
|
170
|
+
session.uid,
|
|
171
|
+
config.password || config.apiKey,
|
|
172
|
+
model,
|
|
173
|
+
'search_read',
|
|
174
|
+
[domain],
|
|
175
|
+
{ fields, limit, offset, order, context }
|
|
176
|
+
];
|
|
177
|
+
return await jsonRpc('object', 'execute_kw', args);
|
|
178
|
+
}
|
|
179
|
+
export async function call(model, method, args = [], kwargs = {}) {
|
|
180
|
+
if (!isAuthenticated() || !config || !session) {
|
|
181
|
+
throw new Error('[metaowl] Not authenticated. Call authenticate() first.');
|
|
182
|
+
}
|
|
183
|
+
const rpcArgs = [
|
|
184
|
+
config.database,
|
|
185
|
+
session.uid,
|
|
186
|
+
config.password || config.apiKey,
|
|
187
|
+
model,
|
|
188
|
+
method,
|
|
189
|
+
args,
|
|
190
|
+
kwargs
|
|
191
|
+
];
|
|
192
|
+
return await jsonRpc('object', 'execute_kw', rpcArgs);
|
|
193
|
+
}
|
|
194
|
+
export async function read(model, ids, fields = []) {
|
|
195
|
+
return await call(model, 'read', [ids], { fields });
|
|
196
|
+
}
|
|
197
|
+
export async function create(model, values) {
|
|
198
|
+
return await call(model, 'create', [[values]]);
|
|
199
|
+
}
|
|
200
|
+
export async function write(model, ids, values) {
|
|
201
|
+
return await call(model, 'write', [ids, values]);
|
|
202
|
+
}
|
|
203
|
+
export async function unlink(model, ids) {
|
|
204
|
+
return await call(model, 'unlink', [ids]);
|
|
205
|
+
}
|
|
206
|
+
export async function searchCount(model, domain = []) {
|
|
207
|
+
return await call(model, 'search_count', [domain]);
|
|
208
|
+
}
|
|
209
|
+
export async function listDatabases() {
|
|
210
|
+
return await jsonRpc('db', 'list', []);
|
|
211
|
+
}
|
|
212
|
+
export async function versionInfo() {
|
|
213
|
+
if (!config) {
|
|
214
|
+
throw new Error('[metaowl] OdooService not configured. Call configure() first.');
|
|
215
|
+
}
|
|
216
|
+
const response = await fetch(`${config.baseUrl}/web/webclient/version_info`, {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'Content-Type': 'application/json' },
|
|
219
|
+
body: '{}'
|
|
220
|
+
});
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(`[metaowl] Failed to get version info: ${response.status}`);
|
|
223
|
+
}
|
|
224
|
+
const data = await response.json();
|
|
225
|
+
return data.result;
|
|
226
|
+
}
|
|
227
|
+
export function onAuthChange(callback) {
|
|
228
|
+
authListeners.push(callback);
|
|
229
|
+
return () => {
|
|
230
|
+
const index = authListeners.indexOf(callback);
|
|
231
|
+
if (index > -1) {
|
|
232
|
+
authListeners.splice(index, 1);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function notifyAuthListeners() {
|
|
237
|
+
for (const listener of authListeners) {
|
|
238
|
+
try {
|
|
239
|
+
listener(session);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Ignore listener errors
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
export const OdooService = {
|
|
247
|
+
configure,
|
|
248
|
+
getConfig,
|
|
249
|
+
isConfigured,
|
|
250
|
+
authenticate,
|
|
251
|
+
isAuthenticated,
|
|
252
|
+
getSession,
|
|
253
|
+
logout,
|
|
254
|
+
searchRead,
|
|
255
|
+
call,
|
|
256
|
+
read,
|
|
257
|
+
create,
|
|
258
|
+
write,
|
|
259
|
+
unlink,
|
|
260
|
+
searchCount,
|
|
261
|
+
listDatabases,
|
|
262
|
+
versionInfo,
|
|
263
|
+
onAuthChange
|
|
264
|
+
};
|
|
265
|
+
export default OdooService;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module PWA
|
|
3
|
+
*
|
|
4
|
+
* Progressive Web App utilities for MetaOwl applications.
|
|
5
|
+
*/
|
|
6
|
+
export function generateManifest(options) {
|
|
7
|
+
const { name, shortName, description, startUrl = './', display = 'standalone', themeColor = '#000000', backgroundColor = '#ffffff', scope = './', icons = [] } = options;
|
|
8
|
+
const manifest = {
|
|
9
|
+
name,
|
|
10
|
+
short_name: shortName,
|
|
11
|
+
start_url: startUrl,
|
|
12
|
+
display,
|
|
13
|
+
theme_color: themeColor,
|
|
14
|
+
background_color: backgroundColor,
|
|
15
|
+
scope,
|
|
16
|
+
orientation: 'any'
|
|
17
|
+
};
|
|
18
|
+
if (description) {
|
|
19
|
+
manifest.description = description;
|
|
20
|
+
}
|
|
21
|
+
if (icons.length > 0) {
|
|
22
|
+
manifest.icons = icons;
|
|
23
|
+
}
|
|
24
|
+
return manifest;
|
|
25
|
+
}
|
|
26
|
+
export async function registerServiceWorker(path, options = {}) {
|
|
27
|
+
const { onUpdate, onReady } = options;
|
|
28
|
+
if (!('serviceWorker' in navigator)) {
|
|
29
|
+
console.warn('[PWA] Service workers not supported');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const registration = await navigator.serviceWorker.register(path);
|
|
34
|
+
registration.addEventListener('updatefound', () => {
|
|
35
|
+
const newWorker = registration.installing;
|
|
36
|
+
if (!newWorker)
|
|
37
|
+
return;
|
|
38
|
+
newWorker.addEventListener('statechange', () => {
|
|
39
|
+
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
|
40
|
+
if (onUpdate) {
|
|
41
|
+
onUpdate(registration);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (newWorker.state === 'activated') {
|
|
45
|
+
if (onReady) {
|
|
46
|
+
onReady(registration);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
if (registration.active && onReady) {
|
|
52
|
+
onReady(registration);
|
|
53
|
+
}
|
|
54
|
+
return registration;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error('[PWA] Service worker registration failed:', error);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function unregisterServiceWorker() {
|
|
62
|
+
if (!('serviceWorker' in navigator)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const registration = await navigator.serviceWorker.ready;
|
|
67
|
+
await registration.unregister();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function isStandalone() {
|
|
75
|
+
if (window.matchMedia) {
|
|
76
|
+
const standaloneNavigator = window.navigator;
|
|
77
|
+
return window.matchMedia('(display-mode: standalone)').matches || standaloneNavigator.standalone === true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
export function isOnline() {
|
|
82
|
+
return navigator.onLine;
|
|
83
|
+
}
|
|
84
|
+
export function subscribeToConnectivity(callbacks) {
|
|
85
|
+
const { onOnline, onOffline } = callbacks;
|
|
86
|
+
const handleOnline = () => {
|
|
87
|
+
if (onOnline)
|
|
88
|
+
onOnline();
|
|
89
|
+
};
|
|
90
|
+
const handleOffline = () => {
|
|
91
|
+
if (onOffline)
|
|
92
|
+
onOffline();
|
|
93
|
+
};
|
|
94
|
+
window.addEventListener('online', handleOnline);
|
|
95
|
+
window.addEventListener('offline', handleOffline);
|
|
96
|
+
return () => {
|
|
97
|
+
window.removeEventListener('online', handleOnline);
|
|
98
|
+
window.removeEventListener('offline', handleOffline);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export async function requestPersistentStorage() {
|
|
102
|
+
if (navigator.storage?.persist) {
|
|
103
|
+
return await navigator.storage.persist();
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
export async function getStorageInfo() {
|
|
108
|
+
if (navigator.storage?.estimate) {
|
|
109
|
+
return await navigator.storage.estimate();
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
export async function sync(tag) {
|
|
114
|
+
if (!('serviceWorker' in navigator)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const registration = await navigator.serviceWorker.ready;
|
|
119
|
+
const syncRegistration = registration;
|
|
120
|
+
if (syncRegistration.sync) {
|
|
121
|
+
await syncRegistration.sync.register(tag);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Background sync not supported or failed
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
export async function subscribeToPush(options) {
|
|
131
|
+
const { serverUrl, publicKey } = options;
|
|
132
|
+
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
133
|
+
console.warn('[PWA] Push notifications not supported');
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const permission = await Notification.requestPermission();
|
|
138
|
+
if (permission !== 'granted') {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const registration = await navigator.serviceWorker.ready;
|
|
142
|
+
const subscription = await registration.pushManager.subscribe({
|
|
143
|
+
userVisibleOnly: true,
|
|
144
|
+
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
|
145
|
+
});
|
|
146
|
+
if (serverUrl) {
|
|
147
|
+
await fetch(serverUrl, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify(subscription)
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return subscription;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.error('[PWA] Push subscription failed:', error);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export async function unsubscribeFromPush() {
|
|
161
|
+
if (!('serviceWorker' in navigator)) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const registration = await navigator.serviceWorker.ready;
|
|
166
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
167
|
+
if (subscription) {
|
|
168
|
+
await subscription.unsubscribe();
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Ignore errors
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
export async function showNotification(title, options = {}) {
|
|
178
|
+
if (!('serviceWorker' in navigator)) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const registration = await navigator.serviceWorker.ready;
|
|
183
|
+
await registration.showNotification(title, options);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
console.error('[PWA] Show notification failed:', error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function urlBase64ToUint8Array(base64String) {
|
|
190
|
+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
191
|
+
const base64 = (base64String + padding)
|
|
192
|
+
.replace(/-/g, '+')
|
|
193
|
+
.replace(/_/g, '/');
|
|
194
|
+
const rawData = window.atob(base64);
|
|
195
|
+
const outputArray = new Uint8Array(rawData.length);
|
|
196
|
+
for (let index = 0; index < rawData.length; ++index) {
|
|
197
|
+
outputArray[index] = rawData.charCodeAt(index);
|
|
198
|
+
}
|
|
199
|
+
return outputArray;
|
|
200
|
+
}
|
|
201
|
+
export const cache = {
|
|
202
|
+
async add(cacheName, urls) {
|
|
203
|
+
if (!('caches' in window))
|
|
204
|
+
return;
|
|
205
|
+
const cacheStorage = await caches.open(cacheName);
|
|
206
|
+
await cacheStorage.addAll(urls);
|
|
207
|
+
},
|
|
208
|
+
async remove(cacheName, urls) {
|
|
209
|
+
if (!('caches' in window))
|
|
210
|
+
return;
|
|
211
|
+
const cacheStorage = await caches.open(cacheName);
|
|
212
|
+
for (const url of urls) {
|
|
213
|
+
await cacheStorage.delete(url);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
async clear() {
|
|
217
|
+
if (!('caches' in window))
|
|
218
|
+
return;
|
|
219
|
+
const cacheKeys = await caches.keys();
|
|
220
|
+
await Promise.all(cacheKeys.map((key) => caches.delete(key)));
|
|
221
|
+
},
|
|
222
|
+
async info() {
|
|
223
|
+
if (!('caches' in window))
|
|
224
|
+
return [];
|
|
225
|
+
const cacheKeys = await caches.keys();
|
|
226
|
+
const info = [];
|
|
227
|
+
for (const key of cacheKeys) {
|
|
228
|
+
const cacheStorage = await caches.open(key);
|
|
229
|
+
const requests = await cacheStorage.keys();
|
|
230
|
+
info.push({ name: key, size: requests.length });
|
|
231
|
+
}
|
|
232
|
+
return info;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
export async function checkCapabilities() {
|
|
236
|
+
let backgroundSync = false;
|
|
237
|
+
if ('serviceWorker' in navigator) {
|
|
238
|
+
try {
|
|
239
|
+
const registration = await navigator.serviceWorker.ready;
|
|
240
|
+
backgroundSync = 'sync' in registration;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Service worker not available
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
serviceWorker: 'serviceWorker' in navigator,
|
|
248
|
+
push: 'PushManager' in window,
|
|
249
|
+
notifications: 'Notification' in window,
|
|
250
|
+
backgroundSync,
|
|
251
|
+
persistentStorage: Boolean(navigator.storage?.persist),
|
|
252
|
+
addToHomeScreen: !isStandalone(),
|
|
253
|
+
offline: 'serviceWorker' in navigator
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
export const PWA = {
|
|
257
|
+
generateManifest,
|
|
258
|
+
registerServiceWorker,
|
|
259
|
+
unregisterServiceWorker,
|
|
260
|
+
isStandalone,
|
|
261
|
+
isOnline,
|
|
262
|
+
subscribeToConnectivity,
|
|
263
|
+
requestPersistentStorage,
|
|
264
|
+
getStorageInfo,
|
|
265
|
+
sync,
|
|
266
|
+
subscribeToPush,
|
|
267
|
+
unsubscribeFromPush,
|
|
268
|
+
showNotification,
|
|
269
|
+
cache,
|
|
270
|
+
checkCapabilities
|
|
271
|
+
};
|
|
272
|
+
export default PWA;
|