@glance-apps/sync 0.2.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/LICENSE +21 -0
- package/README.md +2 -0
- package/api/webdav-proxy.js +147 -0
- package/package.json +25 -0
- package/src/autoBackup.js +1 -0
- package/src/crypto.js +1 -0
- package/src/engine.js +1 -0
- package/src/index.js +9 -0
- package/src/merge.js +884 -0
- package/src/providers.js +1 -0
- package/types/index.d.ts +5 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 krelltunez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Disable Vercel's default body parser so we can forward raw request bodies
|
|
2
|
+
// (e.g. text/calendar) without them being mangled or rejected as unsupported.
|
|
3
|
+
export const config = {
|
|
4
|
+
api: {
|
|
5
|
+
bodyParser: false,
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function validateProxyUrl(urlString) {
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = new URL(urlString);
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error('Invalid URL');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
18
|
+
throw new Error('Only http and https URLs are allowed');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
22
|
+
|
|
23
|
+
if (hostname === 'localhost' || hostname === '0.0.0.0') {
|
|
24
|
+
throw new Error('Private/reserved addresses are not allowed');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Block IPv4 private/reserved ranges
|
|
28
|
+
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
29
|
+
if (ipv4) {
|
|
30
|
+
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
|
|
31
|
+
if (
|
|
32
|
+
a === 10 ||
|
|
33
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
34
|
+
(a === 192 && b === 168) ||
|
|
35
|
+
a === 127 ||
|
|
36
|
+
(a === 169 && b === 254) ||
|
|
37
|
+
a === 0 ||
|
|
38
|
+
(a === 100 && b >= 64 && b <= 127)
|
|
39
|
+
) {
|
|
40
|
+
throw new Error('Private/reserved addresses are not allowed');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Block IPv6 loopback, unspecified, link-local, and private/ULA ranges.
|
|
45
|
+
// ::1 — loopback
|
|
46
|
+
// :: — unspecified
|
|
47
|
+
// ::ffff:… — IPv4-mapped (e.g. ::ffff:127.0.0.1 bypasses the IPv4 check above)
|
|
48
|
+
// fe80:… — link-local
|
|
49
|
+
// fc… / fd… — Unique Local (ULA, fc00::/7); original code only blocked fd
|
|
50
|
+
// NOTE: DNS rebinding (public hostname → private IP at connection time) is a
|
|
51
|
+
// known limitation that cannot be fixed without a post-connection IP check,
|
|
52
|
+
// which fetch() does not expose. Risk is low on Vercel (IPv4-only runtime).
|
|
53
|
+
if (
|
|
54
|
+
hostname === '::1' ||
|
|
55
|
+
hostname === '::' ||
|
|
56
|
+
/^::ffff:/i.test(hostname) ||
|
|
57
|
+
/^fe80:/i.test(hostname) ||
|
|
58
|
+
/^fc/i.test(hostname) ||
|
|
59
|
+
/^fd/i.test(hostname)
|
|
60
|
+
) {
|
|
61
|
+
throw new Error('Private/reserved addresses are not allowed');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readRawBody(req) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
let data = '';
|
|
70
|
+
req.on('data', chunk => data += chunk);
|
|
71
|
+
req.on('end', () => resolve(data));
|
|
72
|
+
req.on('error', reject);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default async function handler(req, res) {
|
|
77
|
+
// Handle CORS preflight
|
|
78
|
+
if (req.method === 'OPTIONS') {
|
|
79
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
80
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, MKCOL, PROPFIND, OPTIONS');
|
|
81
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, X-WebDAV-Auth, Content-Type, Depth, If-Match, If-None-Match');
|
|
82
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
83
|
+
return res.status(204).end();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { url } = req.query;
|
|
87
|
+
|
|
88
|
+
if (!url) {
|
|
89
|
+
return res.status(400).json({ error: 'Missing url parameter' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
validateProxyUrl(url);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return res.status(400).json({ error: err.message });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const headers = {};
|
|
100
|
+
|
|
101
|
+
// Only set Content-Type for requests that have a body — sending it on GET/HEAD
|
|
102
|
+
// causes Apache mod_dav to return 404 (unexpected Content-Type on bodyless request).
|
|
103
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
104
|
+
headers['Content-Type'] = req.headers['content-type'] || 'application/octet-stream';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Forward X-WebDAV-Auth as Authorization
|
|
108
|
+
if (req.headers['x-webdav-auth']) {
|
|
109
|
+
headers['Authorization'] = req.headers['x-webdav-auth'];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (req.headers['depth'] !== undefined) {
|
|
113
|
+
headers['Depth'] = req.headers['depth'];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Forward optimistic-concurrency headers.
|
|
117
|
+
if (req.headers['if-match']) {
|
|
118
|
+
headers['If-Match'] = req.headers['if-match'];
|
|
119
|
+
}
|
|
120
|
+
if (req.headers['if-none-match']) {
|
|
121
|
+
headers['If-None-Match'] = req.headers['if-none-match'];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const fetchOptions = {
|
|
125
|
+
method: req.method,
|
|
126
|
+
headers,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
130
|
+
const rawBody = await readRawBody(req);
|
|
131
|
+
if (rawBody) {
|
|
132
|
+
fetchOptions.body = rawBody;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const response = await fetch(url, fetchOptions);
|
|
137
|
+
const body = await response.text();
|
|
138
|
+
|
|
139
|
+
res.setHeader('Content-Type', response.headers.get('content-type') || 'text/plain');
|
|
140
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
141
|
+
const etag = response.headers.get('etag');
|
|
142
|
+
if (etag) res.setHeader('ETag', etag);
|
|
143
|
+
res.status(response.status).send(body);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
res.status(502).json({ error: 'Failed to proxy WebDAV request' });
|
|
146
|
+
}
|
|
147
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@glance-apps/sync",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"types": "types/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"api",
|
|
11
|
+
"types",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "vitest run"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/glance-apps/glance-sync.git"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"vitest": "^3.1.4"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Placeholder — auto-backup module is extracted in Step 5.
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Placeholder — encryption module is extracted in Step 3.
|
package/src/engine.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Placeholder — sync engine is implemented in Step 6.
|
package/src/index.js
ADDED
package/src/merge.js
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge engine for @glance-apps/sync.
|
|
3
|
+
*
|
|
4
|
+
* Instead of "last write wins" at the whole-dataset level, these functions
|
|
5
|
+
* merge arrays by ID — keeping newer versions when both sides have the same
|
|
6
|
+
* item, preserving items that only exist on one side, and respecting
|
|
7
|
+
* tombstones for permanently deleted items.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Last-writer-wins for scalar config fields using per-field updatedAt timestamps.
|
|
11
|
+
// Falls back to remote-wins when neither side has a timestamp (legacy payloads).
|
|
12
|
+
const pickConfigByTs = (localVal, localTs, remoteVal, remoteTs, defaultVal) => {
|
|
13
|
+
const lTime = localTs ? new Date(localTs).getTime() : 0;
|
|
14
|
+
const rTime = remoteTs ? new Date(remoteTs).getTime() : 0;
|
|
15
|
+
const winner = rTime >= lTime ? remoteVal : localVal;
|
|
16
|
+
return winner !== undefined ? winner : defaultVal;
|
|
17
|
+
};
|
|
18
|
+
const newerTs = (a, b) => {
|
|
19
|
+
if (!a && !b) return null;
|
|
20
|
+
if (!a) return b;
|
|
21
|
+
if (!b) return a;
|
|
22
|
+
return new Date(a).getTime() >= new Date(b).getTime() ? a : b;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Structural deep equality — key-order independent.
|
|
26
|
+
// Used instead of JSON.stringify comparisons to avoid spurious dirty flags
|
|
27
|
+
// when two objects have identical content but different key insertion order.
|
|
28
|
+
const deepEqual = (a, b) => {
|
|
29
|
+
if (a === b) return true;
|
|
30
|
+
if (a === null || b === null || typeof a !== typeof b) return false;
|
|
31
|
+
if (typeof a !== 'object') return a === b;
|
|
32
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
33
|
+
if (Array.isArray(a)) {
|
|
34
|
+
if (a.length !== b.length) return false;
|
|
35
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
36
|
+
}
|
|
37
|
+
const keysA = Object.keys(a);
|
|
38
|
+
const keysB = Object.keys(b);
|
|
39
|
+
if (keysA.length !== keysB.length) return false;
|
|
40
|
+
return keysA.every(k => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generic array merge by item ID, preserving local ordering and appending
|
|
45
|
+
* remote-only items at the end.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array} localItems - Items from this device
|
|
48
|
+
* @param {Array} remoteItems - Items from the server
|
|
49
|
+
* @param {Object} deletedIds - Map of item ID → deletion timestamp (tombstones)
|
|
50
|
+
* @param {Date|null} [syncHorizon] - Items older than this date that are local-only
|
|
51
|
+
* are presumed zombie (tombstone pruned on remote) and silently dropped.
|
|
52
|
+
* @param {Object} [options]
|
|
53
|
+
* @param {string} [options.idField='id'] - Field name used as the item identifier
|
|
54
|
+
* @param {string} [options.timestampField='updatedAt'] - Field name used for last-modified comparison
|
|
55
|
+
* @returns {{ merged: Array, localChanged: boolean, remoteChanged: boolean }}
|
|
56
|
+
*/
|
|
57
|
+
export const mergeArrayById = (localItems, remoteItems, deletedIds, syncHorizon = null, { idField = 'id', timestampField = 'updatedAt' } = {}) => {
|
|
58
|
+
const remoteMap = new Map(remoteItems.map(t => [String(t[idField]), t]));
|
|
59
|
+
const localIds = new Set(localItems.map(t => String(t[idField])));
|
|
60
|
+
let localChanged = false;
|
|
61
|
+
let remoteChanged = false;
|
|
62
|
+
const merged = [];
|
|
63
|
+
|
|
64
|
+
// First pass: iterate local items in order
|
|
65
|
+
for (const localItem of localItems) {
|
|
66
|
+
const id = String(localItem[idField]);
|
|
67
|
+
if (deletedIds[id] && new Date(deletedIds[id]) > new Date(localItem[timestampField] || 0)) {
|
|
68
|
+
localChanged = true; // Removed a local item
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const remoteItem = remoteMap.get(id);
|
|
72
|
+
if (remoteItem) {
|
|
73
|
+
const localTime = new Date(localItem[timestampField] || 0);
|
|
74
|
+
const remoteTime = new Date(remoteItem[timestampField] || 0);
|
|
75
|
+
if (remoteTime > localTime) {
|
|
76
|
+
merged.push(remoteItem);
|
|
77
|
+
localChanged = true;
|
|
78
|
+
} else if (localTime > remoteTime) {
|
|
79
|
+
merged.push(localItem);
|
|
80
|
+
remoteChanged = true;
|
|
81
|
+
} else {
|
|
82
|
+
merged.push(localItem); // Equal timestamps — keep local
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
// Only in local. If the item is older than the sync horizon, the remote's
|
|
86
|
+
// tombstone for it was likely pruned — treat it as a zombie rather than
|
|
87
|
+
// uploading it and resurrecting a deleted item.
|
|
88
|
+
if (syncHorizon && localItem[timestampField] && new Date(localItem[timestampField]) < syncHorizon) {
|
|
89
|
+
localChanged = true; // drop silently — presumed zombie
|
|
90
|
+
} else {
|
|
91
|
+
merged.push(localItem);
|
|
92
|
+
remoteChanged = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Second pass: append remote-only items
|
|
98
|
+
for (const remoteItem of remoteItems) {
|
|
99
|
+
const id = String(remoteItem[idField]);
|
|
100
|
+
if (localIds.has(id)) continue;
|
|
101
|
+
if (deletedIds[id] && new Date(deletedIds[id]) > new Date(remoteItem[timestampField] || 0)) {
|
|
102
|
+
remoteChanged = true; // Tell remote this was deleted
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
merged.push(remoteItem);
|
|
106
|
+
localChanged = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { merged, localChanged, remoteChanged };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Merges routine definitions by bucket (day of week), unioning chips by ID
|
|
114
|
+
* and respecting tombstones for deleted chips.
|
|
115
|
+
*
|
|
116
|
+
* Each side's bucket is an array of { id, name } chips. The merge preserves
|
|
117
|
+
* local ordering and appends any remote-only chips at the end — mirroring the
|
|
118
|
+
* mergeArrayById strategy. Chips present in the deletedChipIds tombstone map
|
|
119
|
+
* are excluded from the merged result.
|
|
120
|
+
*
|
|
121
|
+
* @param {Object} localDefs - Local routine definitions (bucket → chip[])
|
|
122
|
+
* @param {Object} remoteDefs - Remote routine definitions (bucket → chip[])
|
|
123
|
+
* @param {Object} [deletedChipIds] - Map of chip ID → deletion timestamp (tombstones)
|
|
124
|
+
* @returns {{ merged: Object, localChanged: boolean, remoteChanged: boolean }}
|
|
125
|
+
*/
|
|
126
|
+
export const mergeRoutineDefinitions = (localDefs, remoteDefs, deletedChipIds = {}) => {
|
|
127
|
+
const allBuckets = new Set([...Object.keys(localDefs), ...Object.keys(remoteDefs)]);
|
|
128
|
+
const merged = {};
|
|
129
|
+
let localChanged = false;
|
|
130
|
+
let remoteChanged = false;
|
|
131
|
+
|
|
132
|
+
// Check if a chip is suppressed by its tombstone (deletion must be newer than chip)
|
|
133
|
+
const isTombstoned = (chip) => {
|
|
134
|
+
const deleteTime = deletedChipIds[String(chip.id)];
|
|
135
|
+
if (!deleteTime) return false;
|
|
136
|
+
// If chip has lastModified and it's newer than the deletion, chip wins (resurrects)
|
|
137
|
+
if (chip.lastModified && new Date(chip.lastModified) > new Date(deleteTime)) return false;
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
for (const bucket of allBuckets) {
|
|
142
|
+
const localChips = localDefs[bucket] || [];
|
|
143
|
+
const remoteChips = remoteDefs[bucket] || [];
|
|
144
|
+
const localIds = new Set(localChips.map(c => String(c.id)));
|
|
145
|
+
const remoteMap = new Map(remoteChips.map(c => [String(c.id), c]));
|
|
146
|
+
|
|
147
|
+
// Start with local chips in order, filtering out tombstoned chips
|
|
148
|
+
const bucketMerged = [];
|
|
149
|
+
for (const chip of localChips) {
|
|
150
|
+
if (isTombstoned(chip)) {
|
|
151
|
+
localChanged = true; // chip removed locally
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
bucketMerged.push(chip);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Append remote-only chips (skip tombstoned)
|
|
158
|
+
for (const remoteChip of remoteChips) {
|
|
159
|
+
const id = String(remoteChip.id);
|
|
160
|
+
if (localIds.has(id)) continue;
|
|
161
|
+
if (isTombstoned(remoteChip)) {
|
|
162
|
+
remoteChanged = true; // tell remote this was deleted
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
bucketMerged.push(remoteChip);
|
|
166
|
+
localChanged = true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check for local-only chips (remote needs them)
|
|
170
|
+
for (const localChip of localChips) {
|
|
171
|
+
const id = String(localChip.id);
|
|
172
|
+
if (isTombstoned(localChip)) continue; // don't flag deleted chips as needing push
|
|
173
|
+
if (!remoteMap.has(id)) {
|
|
174
|
+
remoteChanged = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
merged[bucket] = bucketMerged;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Bucket only on remote → local needs it
|
|
182
|
+
for (const bucket of Object.keys(remoteDefs)) {
|
|
183
|
+
if (!localDefs[bucket] && remoteDefs[bucket]?.length > 0) {
|
|
184
|
+
// Only flag if there are non-tombstoned chips
|
|
185
|
+
if (remoteDefs[bucket].some(c => !isTombstoned(c))) {
|
|
186
|
+
localChanged = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Bucket only on local → remote needs it
|
|
191
|
+
for (const bucket of Object.keys(localDefs)) {
|
|
192
|
+
if (!remoteDefs[bucket] && localDefs[bucket]?.length > 0) {
|
|
193
|
+
if (localDefs[bucket].some(c => !isTombstoned(c))) {
|
|
194
|
+
remoteChanged = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { merged, localChanged, remoteChanged };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Merges daily notes by date key, keeping the newer version per day.
|
|
204
|
+
* Entries with { deleted: true } are tombstones representing intentional
|
|
205
|
+
* deletions — they participate in the "newer wins" comparison so that a
|
|
206
|
+
* deletion on one device propagates to the other instead of being
|
|
207
|
+
* resurrected by the non-deleted copy.
|
|
208
|
+
*
|
|
209
|
+
* @param {Object} localNotes - Local daily notes { "YYYY-MM-DD": { text, lastModified, deleted? } }
|
|
210
|
+
* @param {Object} remoteNotes - Remote daily notes
|
|
211
|
+
* @returns {{ merged: Object, localChanged: boolean, remoteChanged: boolean }}
|
|
212
|
+
*/
|
|
213
|
+
export const mergeDailyNotes = (localNotes, remoteNotes) => {
|
|
214
|
+
const allDates = new Set([...Object.keys(localNotes), ...Object.keys(remoteNotes)]);
|
|
215
|
+
const merged = {};
|
|
216
|
+
let localChanged = false;
|
|
217
|
+
let remoteChanged = false;
|
|
218
|
+
|
|
219
|
+
for (const dateKey of allDates) {
|
|
220
|
+
const local = localNotes[dateKey];
|
|
221
|
+
const remote = remoteNotes[dateKey];
|
|
222
|
+
|
|
223
|
+
if (local && !remote) {
|
|
224
|
+
// Only local has it — keep it (remote needs it, even if tombstone)
|
|
225
|
+
merged[dateKey] = local;
|
|
226
|
+
remoteChanged = true;
|
|
227
|
+
} else if (!local && remote) {
|
|
228
|
+
// Only remote has it — local needs it
|
|
229
|
+
merged[dateKey] = remote;
|
|
230
|
+
localChanged = true;
|
|
231
|
+
} else {
|
|
232
|
+
// Both have it — newer wins (tombstones included)
|
|
233
|
+
const localTime = new Date(local.lastModified || 0);
|
|
234
|
+
const remoteTime = new Date(remote.lastModified || 0);
|
|
235
|
+
if (remoteTime > localTime) {
|
|
236
|
+
merged[dateKey] = remote;
|
|
237
|
+
localChanged = true;
|
|
238
|
+
} else if (localTime > remoteTime) {
|
|
239
|
+
merged[dateKey] = local;
|
|
240
|
+
remoteChanged = true;
|
|
241
|
+
} else {
|
|
242
|
+
merged[dateKey] = local; // Equal — keep local
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { merged, localChanged, remoteChanged };
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Merges habit arrays by ID, preserving local ordering and appending
|
|
252
|
+
* remote-only habits at the end.
|
|
253
|
+
*
|
|
254
|
+
* Habits don't have lastModified timestamps, so when both sides have the
|
|
255
|
+
* same habit we keep the version with the most recent `createdAt` (in
|
|
256
|
+
* practice they'll be identical). For mutable fields like `archived` and
|
|
257
|
+
* `name`, the version with a newer `createdAt` wins; if equal, local wins.
|
|
258
|
+
*
|
|
259
|
+
* Habits whose ID appears in either side's deletedHabitIds tombstone map
|
|
260
|
+
* are suppressed and the tombstone is propagated to both sides so the
|
|
261
|
+
* deletion persists across devices.
|
|
262
|
+
*
|
|
263
|
+
* @param {Array} localHabits - Habits from this device
|
|
264
|
+
* @param {Array} remoteHabits - Habits from the server
|
|
265
|
+
* @param {Object} localDeletedIds - Map of habit ID → deletion timestamp (tombstones)
|
|
266
|
+
* @param {Object} remoteDeletedIds - Map of habit ID → deletion timestamp (tombstones)
|
|
267
|
+
* @returns {{ merged: Array, mergedDeletedIds: Object, localChanged: boolean, remoteChanged: boolean }}
|
|
268
|
+
*/
|
|
269
|
+
export const mergeHabits = (localHabits, remoteHabits, localDeletedIds = {}, remoteDeletedIds = {}) => {
|
|
270
|
+
// Build the union of tombstones, keeping the most recent deletion timestamp
|
|
271
|
+
const mergedDeletedIds = { ...localDeletedIds };
|
|
272
|
+
for (const [id, ts] of Object.entries(remoteDeletedIds)) {
|
|
273
|
+
if (!mergedDeletedIds[id] || new Date(ts) > new Date(mergedDeletedIds[id])) {
|
|
274
|
+
mergedDeletedIds[id] = ts;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const remoteMap = new Map(remoteHabits.map(h => [String(h.id), h]));
|
|
279
|
+
const localIds = new Set(localHabits.map(h => String(h.id)));
|
|
280
|
+
let localChanged = false;
|
|
281
|
+
let remoteChanged = false;
|
|
282
|
+
const merged = [];
|
|
283
|
+
|
|
284
|
+
for (const localHabit of localHabits) {
|
|
285
|
+
const id = String(localHabit.id);
|
|
286
|
+
// Skip habits suppressed by a tombstone that is newer than the habit itself.
|
|
287
|
+
// If the habit was modified after the deletion (e.g. recreated on another device),
|
|
288
|
+
// the habit's lastModified wins and it is kept — matching task tombstone behaviour.
|
|
289
|
+
if (mergedDeletedIds[id] && new Date(mergedDeletedIds[id]) > new Date(localHabit.lastModified || localHabit.createdAt || 0)) {
|
|
290
|
+
remoteChanged = true; // remote may still have this habit
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const remoteHabit = remoteMap.get(id);
|
|
294
|
+
if (remoteHabit) {
|
|
295
|
+
const localTime = new Date(localHabit.lastModified || localHabit.createdAt || 0);
|
|
296
|
+
const remoteTime = new Date(remoteHabit.lastModified || remoteHabit.createdAt || 0);
|
|
297
|
+
if (remoteTime > localTime) {
|
|
298
|
+
merged.push(remoteHabit);
|
|
299
|
+
localChanged = true;
|
|
300
|
+
} else if (localTime > remoteTime) {
|
|
301
|
+
merged.push(localHabit);
|
|
302
|
+
remoteChanged = true;
|
|
303
|
+
} else {
|
|
304
|
+
// Equal — check for field-level differences (e.g. archived on one side)
|
|
305
|
+
if (!deepEqual(localHabit, remoteHabit)) {
|
|
306
|
+
merged.push(localHabit); // keep local
|
|
307
|
+
remoteChanged = true;
|
|
308
|
+
} else {
|
|
309
|
+
merged.push(localHabit);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
merged.push(localHabit);
|
|
314
|
+
remoteChanged = true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (const remoteHabit of remoteHabits) {
|
|
319
|
+
const id = String(remoteHabit.id);
|
|
320
|
+
if (mergedDeletedIds[id] && new Date(mergedDeletedIds[id]) > new Date(remoteHabit.lastModified || remoteHabit.createdAt || 0)) {
|
|
321
|
+
localChanged = true; // local may still have this habit
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!localIds.has(id)) {
|
|
325
|
+
merged.push(remoteHabit);
|
|
326
|
+
localChanged = true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Propagate merged tombstones if either side is missing entries
|
|
331
|
+
if (Object.keys(mergedDeletedIds).length !== Object.keys(localDeletedIds).length) localChanged = true;
|
|
332
|
+
if (Object.keys(mergedDeletedIds).length !== Object.keys(remoteDeletedIds).length) remoteChanged = true;
|
|
333
|
+
|
|
334
|
+
return { merged, mergedDeletedIds, localChanged, remoteChanged };
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Merges habit logs by date key. Within each date, per-habit counts are
|
|
339
|
+
* merged by taking the maximum value (counts only increase within a day).
|
|
340
|
+
*
|
|
341
|
+
* @param {Object} localLogs - { "YYYY-MM-DD": { habitId: count } }
|
|
342
|
+
* @param {Object} remoteLogs - { "YYYY-MM-DD": { habitId: count } }
|
|
343
|
+
* @param {Object} [localTs] - { "YYYY-MM-DD:habitId": ISO } timestamps for local entries
|
|
344
|
+
* @param {Object} [remoteTs] - { "YYYY-MM-DD:habitId": ISO } timestamps for remote entries
|
|
345
|
+
* @returns {{ merged: Object, mergedTimestamps: Object, localChanged: boolean, remoteChanged: boolean }}
|
|
346
|
+
*/
|
|
347
|
+
export const mergeHabitLogs = (localLogs, remoteLogs, localTs = {}, remoteTs = {}) => {
|
|
348
|
+
const allDates = new Set([...Object.keys(localLogs), ...Object.keys(remoteLogs)]);
|
|
349
|
+
const merged = {};
|
|
350
|
+
const mergedTimestamps = { ...localTs };
|
|
351
|
+
let localChanged = false;
|
|
352
|
+
let remoteChanged = false;
|
|
353
|
+
|
|
354
|
+
// Merge remote timestamps in (keep the newer of each key).
|
|
355
|
+
for (const [k, v] of Object.entries(remoteTs)) {
|
|
356
|
+
if (!mergedTimestamps[k] || new Date(v) > new Date(mergedTimestamps[k])) {
|
|
357
|
+
mergedTimestamps[k] = v;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
for (const dateKey of allDates) {
|
|
362
|
+
const local = localLogs[dateKey];
|
|
363
|
+
const remote = remoteLogs[dateKey];
|
|
364
|
+
|
|
365
|
+
if (local && !remote) {
|
|
366
|
+
merged[dateKey] = local;
|
|
367
|
+
remoteChanged = true;
|
|
368
|
+
} else if (!local && remote) {
|
|
369
|
+
merged[dateKey] = remote;
|
|
370
|
+
localChanged = true;
|
|
371
|
+
} else {
|
|
372
|
+
// Both have it — per-habit merge using timestamps when available, Math.max for legacy.
|
|
373
|
+
const allHabitIds = new Set([...Object.keys(local), ...Object.keys(remote)]);
|
|
374
|
+
const dayMerged = {};
|
|
375
|
+
for (const habitId of allHabitIds) {
|
|
376
|
+
const localCount = local[habitId] !== undefined ? local[habitId] : 0;
|
|
377
|
+
const remoteCount = remote[habitId] !== undefined ? remote[habitId] : 0;
|
|
378
|
+
const tsKey = `${dateKey}:${habitId}`;
|
|
379
|
+
const lTime = localTs[tsKey] ? new Date(localTs[tsKey]).getTime() : 0;
|
|
380
|
+
const rTime = remoteTs[tsKey] ? new Date(remoteTs[tsKey]).getTime() : 0;
|
|
381
|
+
|
|
382
|
+
let winner;
|
|
383
|
+
if (lTime > 0 || rTime > 0) {
|
|
384
|
+
// Timestamp-based: last-writer-wins (handles decrements).
|
|
385
|
+
winner = lTime >= rTime ? localCount : remoteCount;
|
|
386
|
+
if (lTime > rTime && localCount !== remoteCount) remoteChanged = true;
|
|
387
|
+
if (rTime > lTime && localCount !== remoteCount) localChanged = true;
|
|
388
|
+
} else {
|
|
389
|
+
// Legacy (no timestamps): take the max to be safe.
|
|
390
|
+
winner = Math.max(localCount, remoteCount);
|
|
391
|
+
if (remoteCount > localCount) localChanged = true;
|
|
392
|
+
if (localCount > remoteCount) remoteChanged = true;
|
|
393
|
+
}
|
|
394
|
+
dayMerged[habitId] = winner;
|
|
395
|
+
if (local[habitId] === undefined && remote[habitId] !== undefined) localChanged = true;
|
|
396
|
+
if (remote[habitId] === undefined && local[habitId] !== undefined) remoteChanged = true;
|
|
397
|
+
}
|
|
398
|
+
merged[dateKey] = dayMerged;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { merged, mergedTimestamps, localChanged, remoteChanged };
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Prune tombstone entries older than the cutoff date.
|
|
406
|
+
export const pruneTombstones = (tombstones, cutoff) => {
|
|
407
|
+
if (!cutoff) return tombstones;
|
|
408
|
+
const pruned = {};
|
|
409
|
+
for (const [id, ts] of Object.entries(tombstones)) {
|
|
410
|
+
if (new Date(ts) >= cutoff) pruned[id] = ts;
|
|
411
|
+
}
|
|
412
|
+
return pruned;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Full data-level merge: combines local and remote sync snapshots with
|
|
417
|
+
* per-item granularity.
|
|
418
|
+
*
|
|
419
|
+
* Handles: scheduled tasks, inbox tasks, recycle bin, recurring tasks,
|
|
420
|
+
* completed UIDs, tombstones, and cross-list reconciliation.
|
|
421
|
+
*
|
|
422
|
+
* @param {Object} localData - Local sync payload .data
|
|
423
|
+
* @param {Object} remoteData - Remote sync payload .data
|
|
424
|
+
* @returns {{ data: Object, localChanged: boolean, remoteChanged: boolean }}
|
|
425
|
+
*/
|
|
426
|
+
export const mergeSyncData = (localData, remoteData, retentionDays = 90) => {
|
|
427
|
+
// Combine tombstones (permanently deleted task IDs) from both sides
|
|
428
|
+
const localDeleted = localData.deletedTaskIds || {};
|
|
429
|
+
const remoteDeleted = remoteData.deletedTaskIds || {};
|
|
430
|
+
const allDeletedIds = { ...localDeleted };
|
|
431
|
+
for (const [id, ts] of Object.entries(remoteDeleted)) {
|
|
432
|
+
if (!allDeletedIds[id] || new Date(ts) > new Date(allDeletedIds[id])) {
|
|
433
|
+
allDeletedIds[id] = ts;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Fence: any local-only task older than the remote's pruned-before date may
|
|
438
|
+
// have been deleted on another device whose tombstone was already pruned.
|
|
439
|
+
// We suppress resurrection of such tasks rather than uploading them.
|
|
440
|
+
const syncHorizon = remoteData.tombstonePrunedBefore
|
|
441
|
+
? new Date(remoteData.tombstonePrunedBefore)
|
|
442
|
+
: null;
|
|
443
|
+
|
|
444
|
+
// Merge each task list (tasks use 'lastModified' as their timestamp field)
|
|
445
|
+
const taskOpts = { idField: 'id', timestampField: 'lastModified' };
|
|
446
|
+
const tasksMerge = mergeArrayById(localData.tasks || [], remoteData.tasks || [], allDeletedIds, syncHorizon, taskOpts);
|
|
447
|
+
const unschedMerge = mergeArrayById(localData.unscheduledTasks || [], remoteData.unscheduledTasks || [], allDeletedIds, syncHorizon, taskOpts);
|
|
448
|
+
const binMerge = mergeArrayById(localData.recycleBin || [], remoteData.recycleBin || [], allDeletedIds, syncHorizon, taskOpts);
|
|
449
|
+
const recurMerge = mergeArrayById(localData.recurringTasks || [], remoteData.recurringTasks || [], allDeletedIds, syncHorizon, taskOpts);
|
|
450
|
+
|
|
451
|
+
// Combine routine chip tombstones from both sides
|
|
452
|
+
const localDeletedChips = localData.deletedRoutineChipIds || {};
|
|
453
|
+
const remoteDeletedChips = remoteData.deletedRoutineChipIds || {};
|
|
454
|
+
const allDeletedChipIds = { ...localDeletedChips };
|
|
455
|
+
for (const [id, ts] of Object.entries(remoteDeletedChips)) {
|
|
456
|
+
if (!allDeletedChipIds[id] || new Date(ts) > new Date(allDeletedChipIds[id])) {
|
|
457
|
+
allDeletedChipIds[id] = ts;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Merge routine definitions by bucket (with tombstone support)
|
|
462
|
+
const routineMerge = mergeRoutineDefinitions(localData.routineDefinitions || {}, remoteData.routineDefinitions || {}, allDeletedChipIds);
|
|
463
|
+
|
|
464
|
+
// Combine "removed from today" tombstones from both sides.
|
|
465
|
+
// These are separate from deletedRoutineChipIds (which are for permanent
|
|
466
|
+
// definition deletes) — removedTodayRoutineIds tracks routines the user
|
|
467
|
+
// un-checked from Today's Routine without deleting the definition.
|
|
468
|
+
const localRemovedToday = localData.removedTodayRoutineIds || {};
|
|
469
|
+
const remoteRemovedToday = remoteData.removedTodayRoutineIds || {};
|
|
470
|
+
const allRemovedTodayIds = { ...localRemovedToday };
|
|
471
|
+
for (const [id, ts] of Object.entries(remoteRemovedToday)) {
|
|
472
|
+
if (!allRemovedTodayIds[id] || new Date(ts) > new Date(allRemovedTodayIds[id])) {
|
|
473
|
+
allRemovedTodayIds[id] = ts;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Build combined tombstone set for todayRoutines merge: permanent chip
|
|
478
|
+
// deletes + today-specific removals both suppress a routine from reappearing.
|
|
479
|
+
const todayRoutineTombstones = { ...allDeletedChipIds, ...allRemovedTodayIds };
|
|
480
|
+
|
|
481
|
+
// Merge today's selected routines across devices.
|
|
482
|
+
// Only union by ID when both sides are on the same date — if dates differ,
|
|
483
|
+
// keep the newer date's routines (avoids mixing yesterday's with today's).
|
|
484
|
+
const localRoutinesDate = localData.routinesDate || '';
|
|
485
|
+
const remoteRoutinesDate = remoteData.routinesDate || '';
|
|
486
|
+
let todayRoutinesMerge;
|
|
487
|
+
let mergedRoutinesDate;
|
|
488
|
+
if (localRoutinesDate === remoteRoutinesDate) {
|
|
489
|
+
todayRoutinesMerge = mergeArrayById(localData.todayRoutines || [], remoteData.todayRoutines || [], todayRoutineTombstones, syncHorizon, taskOpts);
|
|
490
|
+
mergedRoutinesDate = localRoutinesDate;
|
|
491
|
+
} else if (localRoutinesDate > remoteRoutinesDate) {
|
|
492
|
+
todayRoutinesMerge = { merged: localData.todayRoutines || [], localChanged: false, remoteChanged: true };
|
|
493
|
+
mergedRoutinesDate = localRoutinesDate;
|
|
494
|
+
} else {
|
|
495
|
+
todayRoutinesMerge = { merged: remoteData.todayRoutines || [], localChanged: true, remoteChanged: false };
|
|
496
|
+
mergedRoutinesDate = remoteRoutinesDate;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Merge routine completions: union both sides, keeping only entries that match mergedRoutinesDate.
|
|
500
|
+
// If the dates differed above, the losing side's completions are dropped (stale day).
|
|
501
|
+
const localCompletions = localData.routineCompletions || {};
|
|
502
|
+
const remoteCompletions = remoteData.routineCompletions || {};
|
|
503
|
+
const mergedCompletions = {};
|
|
504
|
+
for (const [id, date] of Object.entries(localCompletions)) {
|
|
505
|
+
if (date === mergedRoutinesDate) mergedCompletions[id] = date;
|
|
506
|
+
}
|
|
507
|
+
for (const [id, date] of Object.entries(remoteCompletions)) {
|
|
508
|
+
if (date === mergedRoutinesDate) mergedCompletions[id] = date;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Merge daily notes by date key
|
|
512
|
+
const dailyNotesMerge = mergeDailyNotes(localData.dailyNotes || {}, remoteData.dailyNotes || {});
|
|
513
|
+
|
|
514
|
+
// Merge habits and habit logs
|
|
515
|
+
const localDeletedHabitIds = localData.deletedHabitIds || {};
|
|
516
|
+
const remoteDeletedHabitIds = remoteData.deletedHabitIds || {};
|
|
517
|
+
const habitsMerge = mergeHabits(localData.habits || [], remoteData.habits || [], localDeletedHabitIds, remoteDeletedHabitIds);
|
|
518
|
+
const habitLogsMerge = mergeHabitLogs(
|
|
519
|
+
localData.habitLogs || {},
|
|
520
|
+
remoteData.habitLogs || {},
|
|
521
|
+
localData.habitLogTimestamps || {},
|
|
522
|
+
remoteData.habitLogTimestamps || {}
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
// Detect routine completion changes: did the merge add completions that one side was missing?
|
|
526
|
+
const localCompletionsFiltered = Object.fromEntries(Object.entries(localCompletions).filter(([, d]) => d === mergedRoutinesDate));
|
|
527
|
+
const remoteCompletionsFiltered = Object.fromEntries(Object.entries(remoteCompletions).filter(([, d]) => d === mergedRoutinesDate));
|
|
528
|
+
const completionsLocalChanged = Object.keys(mergedCompletions).some(id => !(id in localCompletionsFiltered));
|
|
529
|
+
const completionsRemoteChanged = Object.keys(mergedCompletions).some(id => !(id in remoteCompletionsFiltered));
|
|
530
|
+
|
|
531
|
+
let localChanged = tasksMerge.localChanged || unschedMerge.localChanged || binMerge.localChanged || recurMerge.localChanged || routineMerge.localChanged || todayRoutinesMerge.localChanged || dailyNotesMerge.localChanged || habitsMerge.localChanged || habitLogsMerge.localChanged || completionsLocalChanged;
|
|
532
|
+
let remoteChanged = tasksMerge.remoteChanged || unschedMerge.remoteChanged || binMerge.remoteChanged || recurMerge.remoteChanged || routineMerge.remoteChanged || todayRoutinesMerge.remoteChanged || dailyNotesMerge.remoteChanged || habitsMerge.remoteChanged || habitLogsMerge.remoteChanged || completionsRemoteChanged;
|
|
533
|
+
|
|
534
|
+
// Reconcile cross-list conflicts: task active on one device, in recycle bin on other
|
|
535
|
+
const recycledMap = new Map(binMerge.merged.map(t => [String(t.id), t]));
|
|
536
|
+
const reconciledTasks = tasksMerge.merged.filter(t => {
|
|
537
|
+
const recycled = recycledMap.get(String(t.id));
|
|
538
|
+
if (!recycled) return true;
|
|
539
|
+
return new Date(t.lastModified || 0) > new Date(recycled.deletedAt || recycled.lastModified || 0);
|
|
540
|
+
});
|
|
541
|
+
const reconciledUnsched = unschedMerge.merged.filter(t => {
|
|
542
|
+
const recycled = recycledMap.get(String(t.id));
|
|
543
|
+
if (!recycled) return true;
|
|
544
|
+
return new Date(t.lastModified || 0) > new Date(recycled.deletedAt || recycled.lastModified || 0);
|
|
545
|
+
});
|
|
546
|
+
const keptActiveIds = new Set([
|
|
547
|
+
...reconciledTasks.map(t => String(t.id)),
|
|
548
|
+
...reconciledUnsched.map(t => String(t.id))
|
|
549
|
+
]);
|
|
550
|
+
const reconciledBin = binMerge.merged.filter(t => !keptActiveIds.has(String(t.id)));
|
|
551
|
+
|
|
552
|
+
// Also reconcile tasks that moved between scheduled ↔ inbox across devices
|
|
553
|
+
const inScheduled = new Map(reconciledTasks.map(t => [String(t.id), t]));
|
|
554
|
+
const inInbox = new Map(reconciledUnsched.map(t => [String(t.id), t]));
|
|
555
|
+
const crossListIds = new Set();
|
|
556
|
+
for (const [id] of inScheduled) {
|
|
557
|
+
if (inInbox.has(id)) crossListIds.add(id);
|
|
558
|
+
}
|
|
559
|
+
let finalTasks = reconciledTasks;
|
|
560
|
+
let finalUnsched = reconciledUnsched;
|
|
561
|
+
|
|
562
|
+
// Apply the ordering from whichever side last explicitly reordered their inbox.
|
|
563
|
+
// Without this, reordering tasks in a project card would never propagate via sync
|
|
564
|
+
// because individual task lastModified timestamps don't change on reorder.
|
|
565
|
+
const localOrderTs = new Date(localData.unscheduledOrderTimestamp || 0);
|
|
566
|
+
const remoteOrderTs = new Date(remoteData.unscheduledOrderTimestamp || 0);
|
|
567
|
+
if (remoteOrderTs > localOrderTs && remoteData.unscheduledTasks?.length) {
|
|
568
|
+
const remoteOrderIds = remoteData.unscheduledTasks.map(t => String(t.id));
|
|
569
|
+
const unschedById = new Map(finalUnsched.map(t => [String(t.id), t]));
|
|
570
|
+
const ordered = [];
|
|
571
|
+
for (const id of remoteOrderIds) {
|
|
572
|
+
if (unschedById.has(id)) ordered.push(unschedById.get(id));
|
|
573
|
+
}
|
|
574
|
+
// append tasks not present in remote order (new local-only tasks)
|
|
575
|
+
const remoteOrderSet = new Set(remoteOrderIds);
|
|
576
|
+
for (const t of finalUnsched) {
|
|
577
|
+
if (!remoteOrderSet.has(String(t.id))) ordered.push(t);
|
|
578
|
+
}
|
|
579
|
+
finalUnsched = ordered;
|
|
580
|
+
localChanged = true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (crossListIds.size > 0) {
|
|
584
|
+
const keepInScheduled = new Set();
|
|
585
|
+
const keepInInbox = new Set();
|
|
586
|
+
for (const id of crossListIds) {
|
|
587
|
+
const sTask = inScheduled.get(id);
|
|
588
|
+
const iTask = inInbox.get(id);
|
|
589
|
+
if (new Date(sTask.lastModified || 0) >= new Date(iTask.lastModified || 0)) {
|
|
590
|
+
keepInScheduled.add(id);
|
|
591
|
+
} else {
|
|
592
|
+
keepInInbox.add(id);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
finalTasks = reconciledTasks.filter(t => !crossListIds.has(String(t.id)) || keepInScheduled.has(String(t.id)));
|
|
596
|
+
finalUnsched = reconciledUnsched.filter(t => !crossListIds.has(String(t.id)) || keepInInbox.has(String(t.id)));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Union of completed task UIDs, pruned to the retention window.
|
|
600
|
+
// UIDs are "icalUid::YYYY-MM-DD"; entries with a parseable date outside the
|
|
601
|
+
// retention window are dropped so the set doesn't grow unboundedly.
|
|
602
|
+
const uidCutoff = retentionDays > 0 ? new Date(Date.now() - retentionDays * 86400000) : null;
|
|
603
|
+
const mergedCompletedUids = [...new Set([
|
|
604
|
+
...(localData.completedTaskUids || []),
|
|
605
|
+
...(remoteData.completedTaskUids || [])
|
|
606
|
+
])].filter(uid => {
|
|
607
|
+
if (!uidCutoff) return true;
|
|
608
|
+
const m = uid.match(/::(\d{4}-\d{2}-\d{2})$/);
|
|
609
|
+
return !m || new Date(m[1]) >= uidCutoff;
|
|
610
|
+
});
|
|
611
|
+
if (mergedCompletedUids.length !== (localData.completedTaskUids || []).length) localChanged = true;
|
|
612
|
+
if (mergedCompletedUids.length !== (remoteData.completedTaskUids || []).length) remoteChanged = true;
|
|
613
|
+
|
|
614
|
+
// Combine frame tombstones from both sides
|
|
615
|
+
const localDeletedFrames = localData.deletedFrameIds || {};
|
|
616
|
+
const remoteDeletedFrames = remoteData.deletedFrameIds || {};
|
|
617
|
+
const allDeletedFrameIds = { ...localDeletedFrames };
|
|
618
|
+
for (const [id, ts] of Object.entries(remoteDeletedFrames)) {
|
|
619
|
+
if (!allDeletedFrameIds[id] || new Date(ts) > new Date(allDeletedFrameIds[id])) {
|
|
620
|
+
allDeletedFrameIds[id] = ts;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Check if tombstones changed
|
|
625
|
+
if (Object.keys(allDeletedIds).length !== Object.keys(localDeleted).length) localChanged = true;
|
|
626
|
+
if (Object.keys(allDeletedIds).length !== Object.keys(remoteDeleted).length) remoteChanged = true;
|
|
627
|
+
if (Object.keys(allDeletedChipIds).length !== Object.keys(localDeletedChips).length) localChanged = true;
|
|
628
|
+
if (Object.keys(allDeletedChipIds).length !== Object.keys(remoteDeletedChips).length) remoteChanged = true;
|
|
629
|
+
if (Object.keys(allRemovedTodayIds).length !== Object.keys(localRemovedToday).length) localChanged = true;
|
|
630
|
+
if (Object.keys(allRemovedTodayIds).length !== Object.keys(remoteRemovedToday).length) remoteChanged = true;
|
|
631
|
+
if (Object.keys(allDeletedFrameIds).length !== Object.keys(localDeletedFrames).length) localChanged = true;
|
|
632
|
+
if (Object.keys(allDeletedFrameIds).length !== Object.keys(remoteDeletedFrames).length) remoteChanged = true;
|
|
633
|
+
|
|
634
|
+
// Merge GTD frames by ID, respecting tombstones
|
|
635
|
+
const localFrames = localData.gtdFrames || [];
|
|
636
|
+
const remoteFrames = remoteData.gtdFrames || [];
|
|
637
|
+
const localFrameIds = new Set(localFrames.map(f => String(f.id)));
|
|
638
|
+
const remoteFrameMap = new Map(remoteFrames.map(f => [String(f.id), f]));
|
|
639
|
+
const mergedFrames = [];
|
|
640
|
+
|
|
641
|
+
// Keep local frames that aren't tombstoned, using lastModified to resolve conflicts.
|
|
642
|
+
// When both sides differ but neither has a lastModified timestamp (legacy frames),
|
|
643
|
+
// keep local rather than blindly preferring remote.
|
|
644
|
+
for (const localFrame of localFrames) {
|
|
645
|
+
const id = String(localFrame.id);
|
|
646
|
+
if (allDeletedFrameIds[id] && new Date(allDeletedFrameIds[id]) > new Date(localFrame.lastModified || 0)) {
|
|
647
|
+
localChanged = true; // frame was deleted (tombstone is newer)
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const remoteFrame = remoteFrameMap.get(id);
|
|
651
|
+
if (remoteFrame && !deepEqual(localFrame, remoteFrame)) {
|
|
652
|
+
const localTime = new Date(localFrame.lastModified || 0);
|
|
653
|
+
const remoteTime = new Date(remoteFrame.lastModified || 0);
|
|
654
|
+
if (remoteTime > localTime) {
|
|
655
|
+
mergedFrames.push(remoteFrame);
|
|
656
|
+
localChanged = true;
|
|
657
|
+
} else {
|
|
658
|
+
// Local is newer, or neither side has a timestamp — keep local.
|
|
659
|
+
mergedFrames.push(localFrame);
|
|
660
|
+
remoteChanged = true;
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
mergedFrames.push(localFrame);
|
|
664
|
+
if (!remoteFrame) remoteChanged = true; // local-only → remote needs it
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Append remote-only frames that aren't tombstoned
|
|
668
|
+
for (const remoteFrame of remoteFrames) {
|
|
669
|
+
const id = String(remoteFrame.id);
|
|
670
|
+
if (localFrameIds.has(id)) continue;
|
|
671
|
+
if (allDeletedFrameIds[id] && new Date(allDeletedFrameIds[id]) > new Date(remoteFrame.lastModified || 0)) {
|
|
672
|
+
remoteChanged = true; // tell remote this was deleted
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
mergedFrames.push(remoteFrame);
|
|
676
|
+
localChanged = true;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// habitsEnabled / routinesEnabled: last-writer-wins via per-field timestamps.
|
|
680
|
+
const localHabitsEnabled = localData.habitsEnabled !== undefined ? localData.habitsEnabled : true;
|
|
681
|
+
const remoteHabitsEnabled = remoteData.habitsEnabled !== undefined ? remoteData.habitsEnabled : true;
|
|
682
|
+
const mergedHabitsEnabled = pickConfigByTs(localHabitsEnabled, localData.habitsEnabledUpdatedAt, remoteHabitsEnabled, remoteData.habitsEnabledUpdatedAt, true);
|
|
683
|
+
const mergedHabitsEnabledUpdatedAt = newerTs(localData.habitsEnabledUpdatedAt, remoteData.habitsEnabledUpdatedAt);
|
|
684
|
+
if (mergedHabitsEnabled !== localHabitsEnabled) localChanged = true;
|
|
685
|
+
if (mergedHabitsEnabled !== remoteHabitsEnabled) remoteChanged = true;
|
|
686
|
+
|
|
687
|
+
// When neither device has set routinesEnabled, emit undefined so the app
|
|
688
|
+
// supplies the default — avoids propagating a synthetic true to old clients.
|
|
689
|
+
const bothRoutinesUnset = localData.routinesEnabled === undefined && remoteData.routinesEnabled === undefined;
|
|
690
|
+
const localRoutinesEnabled = localData.routinesEnabled !== undefined ? localData.routinesEnabled : true;
|
|
691
|
+
const remoteRoutinesEnabled = remoteData.routinesEnabled !== undefined ? remoteData.routinesEnabled : true;
|
|
692
|
+
const mergedRoutinesEnabled = bothRoutinesUnset ? undefined : pickConfigByTs(localRoutinesEnabled, localData.routinesEnabledUpdatedAt, remoteRoutinesEnabled, remoteData.routinesEnabledUpdatedAt, true);
|
|
693
|
+
const mergedRoutinesEnabledUpdatedAt = newerTs(localData.routinesEnabledUpdatedAt, remoteData.routinesEnabledUpdatedAt);
|
|
694
|
+
if (!bothRoutinesUnset && mergedRoutinesEnabled !== localRoutinesEnabled) localChanged = true;
|
|
695
|
+
if (!bothRoutinesUnset && mergedRoutinesEnabled !== remoteRoutinesEnabled) remoteChanged = true;
|
|
696
|
+
|
|
697
|
+
// Combine goal and project tombstones from both sides
|
|
698
|
+
const localDeletedGoalIds = localData.deletedGoalIds || {};
|
|
699
|
+
const remoteDeletedGoalIds = remoteData.deletedGoalIds || {};
|
|
700
|
+
const allDeletedGoalIds = { ...localDeletedGoalIds };
|
|
701
|
+
for (const [id, ts] of Object.entries(remoteDeletedGoalIds)) {
|
|
702
|
+
if (!allDeletedGoalIds[id] || new Date(ts) > new Date(allDeletedGoalIds[id])) {
|
|
703
|
+
allDeletedGoalIds[id] = ts;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const localDeletedProjectIds = localData.deletedProjectIds || {};
|
|
707
|
+
const remoteDeletedProjectIds = remoteData.deletedProjectIds || {};
|
|
708
|
+
const allDeletedProjectIds = { ...localDeletedProjectIds };
|
|
709
|
+
for (const [id, ts] of Object.entries(remoteDeletedProjectIds)) {
|
|
710
|
+
if (!allDeletedProjectIds[id] || new Date(ts) > new Date(allDeletedProjectIds[id])) {
|
|
711
|
+
allDeletedProjectIds[id] = ts;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Merge goals by ID using updatedAt for conflict resolution (same strategy as frames)
|
|
716
|
+
const localGoals = localData.goals || [];
|
|
717
|
+
const remoteGoals = remoteData.goals || [];
|
|
718
|
+
const localGoalIds = new Set(localGoals.map(g => String(g.id)));
|
|
719
|
+
const remoteGoalMap = new Map(remoteGoals.map(g => [String(g.id), g]));
|
|
720
|
+
const mergedGoals = [];
|
|
721
|
+
for (const localGoal of localGoals) {
|
|
722
|
+
const id = String(localGoal.id);
|
|
723
|
+
if (allDeletedGoalIds[id] && new Date(allDeletedGoalIds[id]) > new Date(localGoal.updatedAt || 0)) {
|
|
724
|
+
localChanged = true; // goal was deleted
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const remoteGoal = remoteGoalMap.get(id);
|
|
728
|
+
if (remoteGoal && !deepEqual(localGoal, remoteGoal)) {
|
|
729
|
+
const localTime = new Date(localGoal.updatedAt || 0);
|
|
730
|
+
const remoteTime = new Date(remoteGoal.updatedAt || 0);
|
|
731
|
+
if (remoteTime > localTime) {
|
|
732
|
+
mergedGoals.push(remoteGoal);
|
|
733
|
+
localChanged = true;
|
|
734
|
+
} else {
|
|
735
|
+
mergedGoals.push(localGoal);
|
|
736
|
+
if (remoteTime < localTime) remoteChanged = true;
|
|
737
|
+
}
|
|
738
|
+
} else {
|
|
739
|
+
mergedGoals.push(localGoal);
|
|
740
|
+
if (!remoteGoal) remoteChanged = true;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
for (const remoteGoal of remoteGoals) {
|
|
744
|
+
if (localGoalIds.has(String(remoteGoal.id))) continue;
|
|
745
|
+
if (allDeletedGoalIds[String(remoteGoal.id)] && new Date(allDeletedGoalIds[String(remoteGoal.id)]) > new Date(remoteGoal.updatedAt || 0)) {
|
|
746
|
+
remoteChanged = true; // tell remote this was deleted
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
mergedGoals.push(remoteGoal);
|
|
750
|
+
localChanged = true;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Merge projects by ID using updatedAt for conflict resolution (same strategy as frames)
|
|
754
|
+
const localProjects = localData.projects || [];
|
|
755
|
+
const remoteProjects = remoteData.projects || [];
|
|
756
|
+
const localProjectIds = new Set(localProjects.map(p => String(p.id)));
|
|
757
|
+
const remoteProjectMap = new Map(remoteProjects.map(p => [String(p.id), p]));
|
|
758
|
+
const mergedProjects = [];
|
|
759
|
+
for (const localProject of localProjects) {
|
|
760
|
+
const id = String(localProject.id);
|
|
761
|
+
if (allDeletedProjectIds[id] && new Date(allDeletedProjectIds[id]) > new Date(localProject.updatedAt || 0)) {
|
|
762
|
+
localChanged = true; // project was deleted
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const remoteProject = remoteProjectMap.get(id);
|
|
766
|
+
if (remoteProject && !deepEqual(localProject, remoteProject)) {
|
|
767
|
+
const localTime = new Date(localProject.updatedAt || 0);
|
|
768
|
+
const remoteTime = new Date(remoteProject.updatedAt || 0);
|
|
769
|
+
if (remoteTime > localTime) {
|
|
770
|
+
mergedProjects.push(remoteProject);
|
|
771
|
+
localChanged = true;
|
|
772
|
+
} else {
|
|
773
|
+
mergedProjects.push(localProject);
|
|
774
|
+
if (remoteTime < localTime) remoteChanged = true;
|
|
775
|
+
}
|
|
776
|
+
} else {
|
|
777
|
+
mergedProjects.push(localProject);
|
|
778
|
+
if (!remoteProject) remoteChanged = true;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
for (const remoteProject of remoteProjects) {
|
|
782
|
+
if (localProjectIds.has(String(remoteProject.id))) continue;
|
|
783
|
+
if (allDeletedProjectIds[String(remoteProject.id)] && new Date(allDeletedProjectIds[String(remoteProject.id)]) > new Date(remoteProject.updatedAt || 0)) {
|
|
784
|
+
remoteChanged = true; // tell remote this was deleted
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
mergedProjects.push(remoteProject);
|
|
788
|
+
localChanged = true;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// goalsProjectsEnabled: last-writer-wins
|
|
792
|
+
const localGoalsProjectsEnabled = localData.goalsProjectsEnabled !== undefined ? localData.goalsProjectsEnabled : false;
|
|
793
|
+
const remoteGoalsProjectsEnabled = remoteData.goalsProjectsEnabled !== undefined ? remoteData.goalsProjectsEnabled : false;
|
|
794
|
+
const mergedGoalsProjectsEnabled = pickConfigByTs(localGoalsProjectsEnabled, localData.goalsProjectsEnabledUpdatedAt, remoteGoalsProjectsEnabled, remoteData.goalsProjectsEnabledUpdatedAt, false);
|
|
795
|
+
const mergedGoalsProjectsEnabledUpdatedAt = newerTs(localData.goalsProjectsEnabledUpdatedAt, remoteData.goalsProjectsEnabledUpdatedAt);
|
|
796
|
+
if (mergedGoalsProjectsEnabled !== localGoalsProjectsEnabled) localChanged = true;
|
|
797
|
+
if (mergedGoalsProjectsEnabled !== remoteGoalsProjectsEnabled) remoteChanged = true;
|
|
798
|
+
|
|
799
|
+
// obsidianConfig change detection (merge happens in return object via pickConfigByTs above)
|
|
800
|
+
const mergedObsidianConfig = pickConfigByTs(localData.obsidianConfig, localData.obsidianConfigUpdatedAt, remoteData.obsidianConfig, remoteData.obsidianConfigUpdatedAt, null);
|
|
801
|
+
if (JSON.stringify(mergedObsidianConfig) !== JSON.stringify(localData.obsidianConfig ?? null)) localChanged = true;
|
|
802
|
+
if (JSON.stringify(mergedObsidianConfig) !== JSON.stringify(remoteData.obsidianConfig ?? null)) remoteChanged = true;
|
|
803
|
+
|
|
804
|
+
// Detect calendar URL changes so the sync cycle completes even when URLs
|
|
805
|
+
// are the only difference between local and remote.
|
|
806
|
+
const mergedSyncUrl = remoteData.syncUrl || localData.syncUrl || '';
|
|
807
|
+
const mergedTaskCalUrl = remoteData.taskCalendarUrl || localData.taskCalendarUrl || '';
|
|
808
|
+
if (mergedSyncUrl !== (localData.syncUrl || '')) localChanged = true;
|
|
809
|
+
if (mergedSyncUrl !== (remoteData.syncUrl || '')) remoteChanged = true;
|
|
810
|
+
if (mergedTaskCalUrl !== (localData.taskCalendarUrl || '')) localChanged = true;
|
|
811
|
+
if (mergedTaskCalUrl !== (remoteData.taskCalendarUrl || '')) remoteChanged = true;
|
|
812
|
+
|
|
813
|
+
// Prune tombstones older than the retention window so they don't grow forever.
|
|
814
|
+
// Pruning happens after merge resolution so stale tombstones still participate
|
|
815
|
+
// in the current merge cycle before being discarded.
|
|
816
|
+
const tombstoneCutoff = retentionDays > 0 ? new Date(Date.now() - retentionDays * 86400000) : null;
|
|
817
|
+
// The merged fence is the later of the two sides' pruned-before dates so
|
|
818
|
+
// downstream devices know the most aggressive point up to which tombstones
|
|
819
|
+
// may be missing.
|
|
820
|
+
const localFence = localData.tombstonePrunedBefore ? new Date(localData.tombstonePrunedBefore) : null;
|
|
821
|
+
const remoteFence = remoteData.tombstonePrunedBefore ? new Date(remoteData.tombstonePrunedBefore) : null;
|
|
822
|
+
const mergedFence = localFence && remoteFence
|
|
823
|
+
? (localFence > remoteFence ? localFence : remoteFence)
|
|
824
|
+
: (localFence || remoteFence || tombstoneCutoff);
|
|
825
|
+
const mergedTombstonePrunedBefore = mergedFence ? mergedFence.toISOString() : null;
|
|
826
|
+
|
|
827
|
+
const prunedDeletedIds = pruneTombstones(allDeletedIds, tombstoneCutoff);
|
|
828
|
+
const prunedDeletedChipIds = pruneTombstones(allDeletedChipIds, tombstoneCutoff);
|
|
829
|
+
const prunedDeletedFrameIds = pruneTombstones(allDeletedFrameIds, tombstoneCutoff);
|
|
830
|
+
const prunedRemovedTodayIds = pruneTombstones(allRemovedTodayIds, tombstoneCutoff);
|
|
831
|
+
const prunedDeletedHabitIds = pruneTombstones(habitsMerge.mergedDeletedIds, tombstoneCutoff);
|
|
832
|
+
const prunedDeletedGoalIds = pruneTombstones(allDeletedGoalIds, tombstoneCutoff);
|
|
833
|
+
const prunedDeletedProjectIds = pruneTombstones(allDeletedProjectIds, tombstoneCutoff);
|
|
834
|
+
|
|
835
|
+
return {
|
|
836
|
+
data: {
|
|
837
|
+
tasks: finalTasks,
|
|
838
|
+
unscheduledTasks: finalUnsched,
|
|
839
|
+
unscheduledOrderTimestamp: remoteOrderTs > localOrderTs
|
|
840
|
+
? remoteData.unscheduledOrderTimestamp
|
|
841
|
+
: localData.unscheduledOrderTimestamp,
|
|
842
|
+
recycleBin: reconciledBin,
|
|
843
|
+
recurringTasks: recurMerge.merged,
|
|
844
|
+
completedTaskUids: mergedCompletedUids,
|
|
845
|
+
deletedTaskIds: prunedDeletedIds,
|
|
846
|
+
deletedRoutineChipIds: prunedDeletedChipIds,
|
|
847
|
+
deletedFrameIds: prunedDeletedFrameIds,
|
|
848
|
+
removedTodayRoutineIds: prunedRemovedTodayIds,
|
|
849
|
+
// Calendar URLs: prefer non-empty value (don't let a device without URLs configured wipe one that has them).
|
|
850
|
+
// If both are non-empty and differ, prefer remote so a URL change propagates across devices.
|
|
851
|
+
syncUrl: (remoteData.syncUrl || localData.syncUrl || ''),
|
|
852
|
+
taskCalendarUrl: (remoteData.taskCalendarUrl || localData.taskCalendarUrl || ''),
|
|
853
|
+
routineDefinitions: routineMerge.merged,
|
|
854
|
+
todayRoutines: todayRoutinesMerge.merged,
|
|
855
|
+
routinesDate: mergedRoutinesDate,
|
|
856
|
+
routineCompletions: mergedCompletions,
|
|
857
|
+
dailyNotes: dailyNotesMerge.merged,
|
|
858
|
+
habits: habitsMerge.merged,
|
|
859
|
+
deletedHabitIds: prunedDeletedHabitIds,
|
|
860
|
+
habitLogs: habitLogsMerge.merged,
|
|
861
|
+
habitLogTimestamps: habitLogsMerge.mergedTimestamps,
|
|
862
|
+
habitsEnabled: mergedHabitsEnabled,
|
|
863
|
+
habitsEnabledUpdatedAt: mergedHabitsEnabledUpdatedAt,
|
|
864
|
+
routinesEnabled: mergedRoutinesEnabled,
|
|
865
|
+
routinesEnabledUpdatedAt: mergedRoutinesEnabledUpdatedAt,
|
|
866
|
+
gtdFrames: mergedFrames,
|
|
867
|
+
goals: mergedGoals,
|
|
868
|
+
deletedGoalIds: prunedDeletedGoalIds,
|
|
869
|
+
projects: mergedProjects,
|
|
870
|
+
deletedProjectIds: prunedDeletedProjectIds,
|
|
871
|
+
goalsProjectsEnabled: mergedGoalsProjectsEnabled,
|
|
872
|
+
goalsProjectsEnabledUpdatedAt: mergedGoalsProjectsEnabledUpdatedAt,
|
|
873
|
+
// obsidianConfig: last-writer-wins via per-field timestamp.
|
|
874
|
+
// On Android/iOS, applyRemoteData only applies the non-native fields.
|
|
875
|
+
obsidianConfig: pickConfigByTs(localData.obsidianConfig, localData.obsidianConfigUpdatedAt, remoteData.obsidianConfig, remoteData.obsidianConfigUpdatedAt, null),
|
|
876
|
+
obsidianConfigUpdatedAt: newerTs(localData.obsidianConfigUpdatedAt, remoteData.obsidianConfigUpdatedAt),
|
|
877
|
+
minimizedSections: localData.minimizedSections, // UI pref — keep local
|
|
878
|
+
use24HourClock: localData.use24HourClock, // device pref — keep local
|
|
879
|
+
tombstonePrunedBefore: mergedTombstonePrunedBefore,
|
|
880
|
+
},
|
|
881
|
+
localChanged,
|
|
882
|
+
remoteChanged
|
|
883
|
+
};
|
|
884
|
+
};
|
package/src/providers.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Placeholder — WebDAV providers are extracted in Step 4.
|