@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/autoBackup.js CHANGED
@@ -1 +1,272 @@
1
- // Placeholder — auto-backup module is extracted in Step 5.
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';