@glance-apps/sync 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.
- package/package.json +1 -1
- package/src/autoBackup.js +272 -1
- package/src/index.js +7 -0
package/package.json
CHANGED
package/src/autoBackup.js
CHANGED
|
@@ -1 +1,272 @@
|
|
|
1
|
-
|
|
1
|
+
import { encryptData, decryptData, isEncryptedEnvelope, hasEncryptionReady } from './crypto.js';
|
|
2
|
+
import { webdavFetch } from './providers.js';
|
|
3
|
+
|
|
4
|
+
// btoa() throws InvalidCharacterError for codepoints > 255 (CJK, emoji, etc.).
|
|
5
|
+
const toBase64 = (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str)));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates an IndexedDB-backed auto-backup store.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} config
|
|
11
|
+
* @param {string} config.autoBackupDBName - IDB database name (e.g. 'dayglance-auto-backups')
|
|
12
|
+
* @returns {object} autoBackupDB instance
|
|
13
|
+
*/
|
|
14
|
+
export function createAutoBackupDB(config) {
|
|
15
|
+
return {
|
|
16
|
+
_db: null,
|
|
17
|
+
async open() {
|
|
18
|
+
if (this._db) return this._db;
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const req = indexedDB.open(config.autoBackupDBName, 1);
|
|
21
|
+
req.onupgradeneeded = (e) => {
|
|
22
|
+
const db = e.target.result;
|
|
23
|
+
if (!db.objectStoreNames.contains('backups')) {
|
|
24
|
+
const store = db.createObjectStore('backups', { keyPath: 'id' });
|
|
25
|
+
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
26
|
+
store.createIndex('frequency', 'frequency', { unique: false });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
req.onsuccess = () => { this._db = req.result; resolve(req.result); };
|
|
30
|
+
req.onerror = () => reject(req.error);
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
async saveBackup(frequency, data) {
|
|
34
|
+
const db = await this.open();
|
|
35
|
+
const timestamp = new Date().toISOString();
|
|
36
|
+
const id = `auto-${frequency}-${timestamp}`;
|
|
37
|
+
const record = { id, timestamp, frequency, data };
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const tx = db.transaction('backups', 'readwrite');
|
|
40
|
+
tx.objectStore('backups').put(record);
|
|
41
|
+
tx.oncomplete = () => resolve(record);
|
|
42
|
+
tx.onerror = () => reject(tx.error);
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
async listBackups(frequency) {
|
|
46
|
+
const db = await this.open();
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const tx = db.transaction('backups', 'readonly');
|
|
49
|
+
const store = tx.objectStore('backups');
|
|
50
|
+
const req = frequency
|
|
51
|
+
? store.index('frequency').getAll(frequency)
|
|
52
|
+
: store.getAll();
|
|
53
|
+
req.onsuccess = () => resolve(req.result.sort((a, b) => b.timestamp.localeCompare(a.timestamp)));
|
|
54
|
+
req.onerror = () => reject(req.error);
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
async getBackup(id) {
|
|
58
|
+
const db = await this.open();
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const tx = db.transaction('backups', 'readonly');
|
|
61
|
+
const req = tx.objectStore('backups').get(id);
|
|
62
|
+
req.onsuccess = () => resolve(req.result);
|
|
63
|
+
req.onerror = () => reject(req.error);
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
async deleteBackup(id) {
|
|
67
|
+
const db = await this.open();
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const tx = db.transaction('backups', 'readwrite');
|
|
70
|
+
tx.objectStore('backups').delete(id);
|
|
71
|
+
tx.oncomplete = () => resolve();
|
|
72
|
+
tx.onerror = () => reject(tx.error);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
async pruneBackups(frequency, maxCount) {
|
|
76
|
+
const all = await this.listBackups(frequency);
|
|
77
|
+
if (all.length <= maxCount) return;
|
|
78
|
+
const toDelete = all.slice(maxCount);
|
|
79
|
+
const db = await this.open();
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const tx = db.transaction('backups', 'readwrite');
|
|
82
|
+
const store = tx.objectStore('backups');
|
|
83
|
+
toDelete.forEach(b => store.delete(b.id));
|
|
84
|
+
tx.oncomplete = () => resolve(toDelete.length);
|
|
85
|
+
tx.onerror = () => reject(tx.error);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates the auto-backup remote provider objects.
|
|
93
|
+
* These are separate from cloudSyncProviders (real-time sync); they handle
|
|
94
|
+
* periodic snapshot uploads.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} config
|
|
97
|
+
* @param {string} config.backupFilenamePrefix - e.g. 'dayglance-backup-'
|
|
98
|
+
* @param {string} config.appFolderName - e.g. 'dayglance' (used in Nextcloud path)
|
|
99
|
+
* @param {Function} config.webdavFetch - bound webdavFetch(method, url, auth, body, extra)
|
|
100
|
+
* @returns {object} provider map keyed by provider id
|
|
101
|
+
*/
|
|
102
|
+
export function createAutoBackupProviders(config) {
|
|
103
|
+
const wdFetch = config.webdavFetch;
|
|
104
|
+
const prefix = config.backupFilenamePrefix;
|
|
105
|
+
const appFolder = config.appFolderName;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
nextcloud: {
|
|
109
|
+
name: 'Nextcloud / WebDAV',
|
|
110
|
+
configFields: [
|
|
111
|
+
{ key: 'nextcloudUrl', label: 'Nextcloud URL', type: 'url', placeholder: 'https://cloud.example.com' },
|
|
112
|
+
{ key: 'username', label: 'Username', type: 'text', placeholder: 'your-username' },
|
|
113
|
+
{ key: 'appPassword', label: 'App Password', type: 'password', placeholder: 'xxxxx-xxxxx-xxxxx-xxxxx-xxxxx' }
|
|
114
|
+
],
|
|
115
|
+
_getBackupDirUrl(providerConfig) {
|
|
116
|
+
if (!providerConfig.nextcloudUrl) throw new Error('Nextcloud URL is not configured');
|
|
117
|
+
return `${providerConfig.nextcloudUrl.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(providerConfig.username)}/${appFolder}/backups/`;
|
|
118
|
+
},
|
|
119
|
+
_getAuthHeaders(providerConfig) {
|
|
120
|
+
return { 'X-WebDAV-Auth': 'Basic ' + toBase64(providerConfig.username + ':' + providerConfig.appPassword) };
|
|
121
|
+
},
|
|
122
|
+
async uploadBackup(providerConfig, data) {
|
|
123
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
124
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
125
|
+
const now = new Date();
|
|
126
|
+
const timestamp = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}-${String(now.getMinutes()).padStart(2,'0')}-${String(now.getSeconds()).padStart(2,'0')}`;
|
|
127
|
+
const filename = `${prefix}${timestamp}.json`;
|
|
128
|
+
const fileUrl = dirUrl + filename;
|
|
129
|
+
const payload = hasEncryptionReady() ? await encryptData(data) : data;
|
|
130
|
+
const body = JSON.stringify(payload);
|
|
131
|
+
|
|
132
|
+
const doUpload = () =>
|
|
133
|
+
wdFetch('PUT', fileUrl, authHeaders, body, { 'Content-Type': 'application/json' });
|
|
134
|
+
|
|
135
|
+
let res = await doUpload();
|
|
136
|
+
if (res.status === 404 || res.status === 409) {
|
|
137
|
+
const parentDir = `${providerConfig.nextcloudUrl.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(providerConfig.username)}/${appFolder}/`;
|
|
138
|
+
await wdFetch('MKCOL', parentDir, authHeaders);
|
|
139
|
+
await wdFetch('MKCOL', dirUrl, authHeaders);
|
|
140
|
+
res = await doUpload();
|
|
141
|
+
}
|
|
142
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
|
143
|
+
return filename;
|
|
144
|
+
},
|
|
145
|
+
async listBackups(providerConfig) {
|
|
146
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
147
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
148
|
+
const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '1' });
|
|
149
|
+
if (res.status === 404) return [];
|
|
150
|
+
if (!res.ok) throw new Error(`List failed: ${res.status}`);
|
|
151
|
+
const xml = await res.text();
|
|
152
|
+
const parser = new DOMParser();
|
|
153
|
+
const doc = parser.parseFromString(xml, 'application/xml');
|
|
154
|
+
const responses = doc.querySelectorAll('response');
|
|
155
|
+
const files = [];
|
|
156
|
+
responses.forEach(r => {
|
|
157
|
+
const href = r.querySelector('href')?.textContent || '';
|
|
158
|
+
const filename = decodeURIComponent(href.split('/').filter(Boolean).pop());
|
|
159
|
+
if (filename.startsWith(prefix) && filename.endsWith('.json')) {
|
|
160
|
+
const lastModified = r.querySelector('getlastmodified')?.textContent;
|
|
161
|
+
files.push({ filename, lastModified: lastModified ? new Date(lastModified).toISOString() : null });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
return files.sort((a, b) => (b.lastModified || '').localeCompare(a.lastModified || ''));
|
|
165
|
+
},
|
|
166
|
+
async downloadBackup(providerConfig, filename) {
|
|
167
|
+
const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
|
|
168
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
169
|
+
const res = await wdFetch('GET', fileUrl, authHeaders);
|
|
170
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
171
|
+
const parsed = await res.json();
|
|
172
|
+
if (isEncryptedEnvelope(parsed)) return decryptData(parsed);
|
|
173
|
+
return parsed;
|
|
174
|
+
},
|
|
175
|
+
async deleteBackup(providerConfig, filename) {
|
|
176
|
+
const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
|
|
177
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
178
|
+
const res = await wdFetch('DELETE', fileUrl, authHeaders);
|
|
179
|
+
if (!res.ok && res.status !== 404) throw new Error(`Delete failed: ${res.status}`);
|
|
180
|
+
},
|
|
181
|
+
async testConnection(providerConfig) {
|
|
182
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
183
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
184
|
+
const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '0' });
|
|
185
|
+
if (res.status === 200 || res.status === 207 || res.status === 404) return { success: true };
|
|
186
|
+
if (res.status === 401) return { success: false, error: 'Invalid credentials.' };
|
|
187
|
+
return { success: false, error: `Unexpected response: ${res.status}` };
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
webdav: {
|
|
191
|
+
name: 'Generic WebDAV',
|
|
192
|
+
configFields: [
|
|
193
|
+
{ key: 'webdavUrl', label: 'WebDAV URL', type: 'url', placeholder: 'https://app.koofr.net/dav/Koofr/dayGLANCE/' },
|
|
194
|
+
{ key: 'username', label: 'Username', type: 'text', placeholder: 'your-username' },
|
|
195
|
+
{ key: 'appPassword', label: 'Password / App Password', type: 'password', placeholder: 'your-password' }
|
|
196
|
+
],
|
|
197
|
+
_getBackupDirUrl(providerConfig) {
|
|
198
|
+
return `${providerConfig.webdavUrl.replace(/\/+$/, '')}/`;
|
|
199
|
+
},
|
|
200
|
+
_getAuthHeaders(providerConfig) {
|
|
201
|
+
return { 'X-WebDAV-Auth': 'Basic ' + toBase64(providerConfig.username + ':' + providerConfig.appPassword) };
|
|
202
|
+
},
|
|
203
|
+
async uploadBackup(providerConfig, data) {
|
|
204
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
205
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
206
|
+
const now = new Date();
|
|
207
|
+
const timestamp = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}-${String(now.getMinutes()).padStart(2,'0')}-${String(now.getSeconds()).padStart(2,'0')}`;
|
|
208
|
+
const filename = `${prefix}${timestamp}.json`;
|
|
209
|
+
const fileUrl = dirUrl + filename;
|
|
210
|
+
const payload = hasEncryptionReady() ? await encryptData(data) : data;
|
|
211
|
+
const body = JSON.stringify(payload);
|
|
212
|
+
const doUpload = () =>
|
|
213
|
+
wdFetch('PUT', fileUrl, authHeaders, body, { 'Content-Type': 'application/json' });
|
|
214
|
+
let res = await doUpload();
|
|
215
|
+
if (res.status === 404 || res.status === 409) {
|
|
216
|
+
await wdFetch('MKCOL', dirUrl, authHeaders);
|
|
217
|
+
res = await doUpload();
|
|
218
|
+
}
|
|
219
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
|
220
|
+
return filename;
|
|
221
|
+
},
|
|
222
|
+
async listBackups(providerConfig) {
|
|
223
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
224
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
225
|
+
const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '1' });
|
|
226
|
+
if (res.status === 404) return [];
|
|
227
|
+
if (!res.ok) throw new Error(`List failed: ${res.status}`);
|
|
228
|
+
const xml = await res.text();
|
|
229
|
+
const parser = new DOMParser();
|
|
230
|
+
const doc = parser.parseFromString(xml, 'application/xml');
|
|
231
|
+
const responses = doc.querySelectorAll('response');
|
|
232
|
+
const files = [];
|
|
233
|
+
responses.forEach(r => {
|
|
234
|
+
const href = r.querySelector('href')?.textContent || '';
|
|
235
|
+
const filename = decodeURIComponent(href.split('/').filter(Boolean).pop());
|
|
236
|
+
if (filename.startsWith(prefix) && filename.endsWith('.json')) {
|
|
237
|
+
const lastModified = r.querySelector('getlastmodified')?.textContent;
|
|
238
|
+
files.push({ filename, lastModified: lastModified ? new Date(lastModified).toISOString() : null });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
return files.sort((a, b) => (b.lastModified || '').localeCompare(a.lastModified || ''));
|
|
242
|
+
},
|
|
243
|
+
async downloadBackup(providerConfig, filename) {
|
|
244
|
+
const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
|
|
245
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
246
|
+
const res = await wdFetch('GET', fileUrl, authHeaders);
|
|
247
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
248
|
+
const parsed = await res.json();
|
|
249
|
+
if (isEncryptedEnvelope(parsed)) return decryptData(parsed);
|
|
250
|
+
return parsed;
|
|
251
|
+
},
|
|
252
|
+
async deleteBackup(providerConfig, filename) {
|
|
253
|
+
const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
|
|
254
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
255
|
+
const res = await wdFetch('DELETE', fileUrl, authHeaders);
|
|
256
|
+
if (!res.ok && res.status !== 404) throw new Error(`Delete failed: ${res.status}`);
|
|
257
|
+
},
|
|
258
|
+
async testConnection(providerConfig) {
|
|
259
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
260
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
261
|
+
const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '0' });
|
|
262
|
+
if (res.status === 200 || res.status === 207 || res.status === 404) return { success: true };
|
|
263
|
+
if (res.status === 401) return { success: false, error: 'Invalid credentials.' };
|
|
264
|
+
return { success: false, error: `Unexpected response: ${res.status}` };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Auto-backup retention limits (app-agnostic constants)
|
|
271
|
+
export const AUTO_BACKUP_RETENTION = { hourly: 24, daily: 30, weekly: 12 };
|
|
272
|
+
export const AUTO_BACKUP_INTERVALS = { hourly: 3600, daily: 86400, weekly: 604800 };
|
package/src/index.js
CHANGED
|
@@ -23,3 +23,10 @@ export {
|
|
|
23
23
|
// webdavFetch and createProviders are internal to the engine (Step 6) but
|
|
24
24
|
// exported here so shims can wire them before the engine exists.
|
|
25
25
|
export { webdavFetch, createProviders } from './providers.js';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
createAutoBackupDB,
|
|
29
|
+
createAutoBackupProviders,
|
|
30
|
+
AUTO_BACKUP_RETENTION,
|
|
31
|
+
AUTO_BACKUP_INTERVALS,
|
|
32
|
+
} from './autoBackup.js';
|