vue2-excel-grid 1.7.2 → 2.0.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/README.md +1 -1
- package/dist/vue2-excel-grid.esm.js +1193 -587
- package/dist/vue2-excel-grid.umd.js +3 -3
- package/package.json +1 -1
- package/src/components/EgLocalSyncModal.vue +99 -48
- package/src/components/ExcelGrid.vue +258 -122
- package/src/utils/dataHash.js +139 -0
- package/src/utils/i18n.js +32 -24
- package/src/utils/idbStore.js +122 -20
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Content-hashing for localSyncKey's version dedup: two saves of the exact same
|
|
2
|
+
// row data (regardless of object key insertion order) must produce the same hash,
|
|
3
|
+
// so ExcelGrid.vue's flush-on-hide logic can tell "this is identical to an
|
|
4
|
+
// existing local version" from "this is new" without deep-comparing full row
|
|
5
|
+
// arrays pairwise against every stored version.
|
|
6
|
+
//
|
|
7
|
+
// The worker below is intentionally NOT built on this project's existing
|
|
8
|
+
// workerFactory.js/.webpack.js + worker-loader pattern (used for Excel import/
|
|
9
|
+
// export) — that pattern only produces a real Worker inside this repo's own
|
|
10
|
+
// `npm run demo` (vue.config.js aliases workerFactory -> workerFactory.webpack.js
|
|
11
|
+
// and registers worker-loader for *.worker.js). Rollup has no such plugin, so the
|
|
12
|
+
// published npm package's workerFactory.js always returns null — host projects
|
|
13
|
+
// that `npm install` this library never get a real worker for import/export,
|
|
14
|
+
// only the synchronous main-thread fallback. That's an acceptable trade-off for
|
|
15
|
+
// import/export (occasional, user-initiated), but would defeat the point here,
|
|
16
|
+
// where "checked via a Web Worker" is meant to keep the main thread responsive at
|
|
17
|
+
// the exact moment the page may be hiding/unloading.
|
|
18
|
+
//
|
|
19
|
+
// Instead, createDataHashWorker() builds a worker from an inline Blob URL. This
|
|
20
|
+
// requires no bundler-specific loader or alias, so it works unmodified in Rollup
|
|
21
|
+
// UMD/ESM, webpack, Vite, or a plain <script> tag — i.e. it actually reaches
|
|
22
|
+
// consumers of the published package, not just this repo's own demo.
|
|
23
|
+
|
|
24
|
+
// Deterministic JSON-ish serialization: object keys are sorted, so two row
|
|
25
|
+
// arrays with identical values but differently-ordered keys still hash the same.
|
|
26
|
+
export function stableStringify(value) {
|
|
27
|
+
if (value === null || typeof value !== 'object') {
|
|
28
|
+
return JSON.stringify(value)
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
return '[' + value.map(stableStringify).join(',') + ']'
|
|
32
|
+
}
|
|
33
|
+
var keys = Object.keys(value).sort()
|
|
34
|
+
return '{' + keys.map(function (k) {
|
|
35
|
+
return JSON.stringify(k) + ':' + stableStringify(value[k])
|
|
36
|
+
}).join(',') + '}'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// djb2 — small, fast, non-cryptographic string hash. Collision-safety needs here
|
|
40
|
+
// are trivial (dedup within one localSyncKey's <= localSyncMaxVersions entries,
|
|
41
|
+
// not a security boundary), so this is deliberately simple rather than a real
|
|
42
|
+
// cryptographic hash.
|
|
43
|
+
export function djb2(str) {
|
|
44
|
+
var hash = 5381
|
|
45
|
+
for (var i = 0; i < str.length; i++) {
|
|
46
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0 // hash*33 + c
|
|
47
|
+
}
|
|
48
|
+
return (hash >>> 0).toString(16) // unsigned hex — stable, compact, easy to store/compare
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Synchronous fallback used when no Worker is available (createDataHashWorker()
|
|
52
|
+
// returned null — old browsers, some privacy modes, and jsdom under Jest).
|
|
53
|
+
export function hashRows(data) {
|
|
54
|
+
return djb2(stableStringify(data))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The worker's ENTIRE program, deliberately self-contained in one function rather
|
|
58
|
+
// than composed from calls to stableStringify/djb2 above, even though the logic
|
|
59
|
+
// is identical. Function.prototype.toString() only reflects a function's OWN
|
|
60
|
+
// source text — if this called out to the exported helpers by reference, a
|
|
61
|
+
// minifier (terser runs on the UMD build, see rollup.config.js) could rename or
|
|
62
|
+
// inline those bindings independently, with no guarantee the extracted text of
|
|
63
|
+
// THIS function still resolves those names correctly once dropped into a
|
|
64
|
+
// worker's separate global scope. Keeping the whole worker program in one
|
|
65
|
+
// function sidesteps that: whatever a minifier does to it, it does consistently
|
|
66
|
+
// to the one function as a unit, since a minifier can't break its own output.
|
|
67
|
+
//
|
|
68
|
+
// This IS a duplicate of stableStringify/djb2/hashRows above. Keep both copies in
|
|
69
|
+
// sync if the hashing algorithm ever changes — tests/unit/dataHash.spec.js checks
|
|
70
|
+
// them against each other for exactly this reason.
|
|
71
|
+
function workerProgram() {
|
|
72
|
+
function stableStringify(value) {
|
|
73
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
74
|
+
if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'
|
|
75
|
+
var keys = Object.keys(value).sort()
|
|
76
|
+
return '{' + keys.map(function (k) {
|
|
77
|
+
return JSON.stringify(k) + ':' + stableStringify(value[k])
|
|
78
|
+
}).join(',') + '}'
|
|
79
|
+
}
|
|
80
|
+
function djb2(str) {
|
|
81
|
+
var hash = 5381
|
|
82
|
+
for (var i = 0; i < str.length; i++) {
|
|
83
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0
|
|
84
|
+
}
|
|
85
|
+
return (hash >>> 0).toString(16)
|
|
86
|
+
}
|
|
87
|
+
self.onmessage = function (e) {
|
|
88
|
+
self.postMessage({ hash: djb2(stableStringify(e.data.data)) })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Returns a ready-to-use Worker, or null if this environment can't support one.
|
|
93
|
+
// Callers must fall back to hashRows() on the main thread when this returns null.
|
|
94
|
+
export function createDataHashWorker() {
|
|
95
|
+
if (
|
|
96
|
+
typeof Worker === 'undefined' ||
|
|
97
|
+
typeof Blob === 'undefined' ||
|
|
98
|
+
typeof URL === 'undefined' ||
|
|
99
|
+
!URL.createObjectURL
|
|
100
|
+
) {
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
var src = '(' + workerProgram.toString() + ')();'
|
|
105
|
+
var blob = new Blob([src], { type: 'application/javascript' })
|
|
106
|
+
var url = URL.createObjectURL(blob)
|
|
107
|
+
var worker = new Worker(url)
|
|
108
|
+
var revoke = function () { URL.revokeObjectURL(url) }
|
|
109
|
+
worker.addEventListener('message', revoke, { once: true })
|
|
110
|
+
worker.addEventListener('error', revoke, { once: true })
|
|
111
|
+
return worker
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// High-level helper used by ExcelGrid.vue: hashes `data` via a Worker when one
|
|
118
|
+
// can be created, transparently falling back to the synchronous hashRows() on
|
|
119
|
+
// the main thread otherwise. Mirrors the one-shot "create -> use -> terminate"
|
|
120
|
+
// worker pattern already used for Excel import/export (see ExcelGrid.vue's
|
|
121
|
+
// _exportViaWorker) — one Promise, worker torn down exactly once, either way.
|
|
122
|
+
// Deliberately never rejects: a hash is always producible one way or another,
|
|
123
|
+
// so callers don't need error-handling for this specific step.
|
|
124
|
+
export function hashRowsAsync(data) {
|
|
125
|
+
var worker = createDataHashWorker()
|
|
126
|
+
if (!worker) return Promise.resolve(hashRows(data))
|
|
127
|
+
return new Promise(function (resolve) {
|
|
128
|
+
var done = false
|
|
129
|
+
var finish = function (hash) {
|
|
130
|
+
if (done) return
|
|
131
|
+
done = true
|
|
132
|
+
worker.terminate()
|
|
133
|
+
resolve(hash)
|
|
134
|
+
}
|
|
135
|
+
worker.onmessage = function (e) { finish(e.data.hash) }
|
|
136
|
+
worker.onerror = function () { finish(hashRows(data)) } // worker failed — fall back, never reject
|
|
137
|
+
worker.postMessage({ data: data })
|
|
138
|
+
})
|
|
139
|
+
}
|
package/src/utils/i18n.js
CHANGED
|
@@ -47,16 +47,18 @@ const messages = {
|
|
|
47
47
|
theme: 'Theme',
|
|
48
48
|
// local sync
|
|
49
49
|
localSync: 'Local sync',
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
localSyncAvailable: 'Local versions are available',
|
|
51
|
+
localSyncTitle: 'Local versions',
|
|
52
|
+
localSyncEmpty: 'No local versions saved yet',
|
|
53
53
|
localSyncRows: 'rows',
|
|
54
|
+
localSyncTouchedAt: 'Reconfirmed',
|
|
55
|
+
localSyncLoading: 'Loading…',
|
|
54
56
|
localSyncSave: 'Save',
|
|
55
|
-
localSyncSaveHint: 'Save the current table
|
|
57
|
+
localSyncSaveHint: 'Save the current table as a new local version',
|
|
56
58
|
localSyncRestore: 'Restore',
|
|
57
|
-
localSyncRestoreHint: '
|
|
59
|
+
localSyncRestoreHint: 'Restore the selected local version into the table',
|
|
58
60
|
localSyncClear: 'Delete',
|
|
59
|
-
localSyncConfirmClear: 'Delete this local
|
|
61
|
+
localSyncConfirmClear: 'Delete this local version?',
|
|
60
62
|
localSyncConfirmYes: 'Yes, delete',
|
|
61
63
|
localSyncConfirmNo: 'Cancel',
|
|
62
64
|
localSyncClose: 'Close',
|
|
@@ -118,16 +120,18 @@ const messages = {
|
|
|
118
120
|
theme: 'Тема',
|
|
119
121
|
// local sync
|
|
120
122
|
localSync: 'Локальная синхронизация',
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
localSyncAvailable: 'Есть локальные версии',
|
|
124
|
+
localSyncTitle: 'Локальные версии',
|
|
125
|
+
localSyncEmpty: 'Локальные версии пока не сохранены',
|
|
124
126
|
localSyncRows: 'строк',
|
|
127
|
+
localSyncTouchedAt: 'Подтверждено',
|
|
128
|
+
localSyncLoading: 'Загрузка…',
|
|
125
129
|
localSyncSave: 'Сохранить',
|
|
126
|
-
localSyncSaveHint: 'Сохранить текущую таблицу
|
|
130
|
+
localSyncSaveHint: 'Сохранить текущую таблицу как новую локальную версию',
|
|
127
131
|
localSyncRestore: 'Восстановить',
|
|
128
|
-
localSyncRestoreHint: '
|
|
132
|
+
localSyncRestoreHint: 'Восстановить выбранную локальную версию в таблицу',
|
|
129
133
|
localSyncClear: 'Удалить',
|
|
130
|
-
localSyncConfirmClear: 'Удалить эту локальную
|
|
134
|
+
localSyncConfirmClear: 'Удалить эту локальную версию?',
|
|
131
135
|
localSyncConfirmYes: 'Да, удалить',
|
|
132
136
|
localSyncConfirmNo: 'Отмена',
|
|
133
137
|
localSyncClose: 'Закрыть',
|
|
@@ -189,16 +193,18 @@ const messages = {
|
|
|
189
193
|
theme: 'Mavzu',
|
|
190
194
|
// local sync
|
|
191
195
|
localSync: 'Lokal sinxronlash',
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
localSyncAvailable: 'Lokal versiyalar mavjud',
|
|
197
|
+
localSyncTitle: 'Lokal versiyalar',
|
|
198
|
+
localSyncEmpty: 'Hali lokal versiya saqlanmagan',
|
|
195
199
|
localSyncRows: 'qator',
|
|
200
|
+
localSyncTouchedAt: 'Tasdiqlangan',
|
|
201
|
+
localSyncLoading: 'Yuklanmoqda…',
|
|
196
202
|
localSyncSave: 'Saqlash',
|
|
197
|
-
localSyncSaveHint: 'Joriy jadvalni lokal
|
|
203
|
+
localSyncSaveHint: 'Joriy jadvalni yangi lokal versiya sifatida saqlash',
|
|
198
204
|
localSyncRestore: 'Tiklash',
|
|
199
|
-
localSyncRestoreHint: '
|
|
205
|
+
localSyncRestoreHint: 'Tanlangan lokal versiyani jadvalga qaytarish',
|
|
200
206
|
localSyncClear: 'O\'chirish',
|
|
201
|
-
localSyncConfirmClear: 'Bu lokal
|
|
207
|
+
localSyncConfirmClear: 'Bu lokal versiya o\'chirilsinmi?',
|
|
202
208
|
localSyncConfirmYes: 'Ha, o\'chirish',
|
|
203
209
|
localSyncConfirmNo: 'Bekor qilish',
|
|
204
210
|
localSyncClose: 'Yopish',
|
|
@@ -260,16 +266,18 @@ const messages = {
|
|
|
260
266
|
theme: 'Мавзу',
|
|
261
267
|
// local sync
|
|
262
268
|
localSync: 'Лакал синхронлаш',
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
269
|
+
localSyncAvailable: 'Локал версиялар мавжуд',
|
|
270
|
+
localSyncTitle: 'Локал версиялар',
|
|
271
|
+
localSyncEmpty: 'Ҳали локал версия сақланмаган',
|
|
266
272
|
localSyncRows: 'қатор',
|
|
273
|
+
localSyncTouchedAt: 'Тасдиқланган',
|
|
274
|
+
localSyncLoading: 'Юкланмоқда…',
|
|
267
275
|
localSyncSave: 'Сақлаш',
|
|
268
|
-
localSyncSaveHint: 'Жорий жадвални
|
|
276
|
+
localSyncSaveHint: 'Жорий жадвални янги локал версия сифатида сақлаш',
|
|
269
277
|
localSyncRestore: 'Тиклаш',
|
|
270
|
-
localSyncRestoreHint: '
|
|
278
|
+
localSyncRestoreHint: 'Танланган локал версияни жадвалга қайтариш',
|
|
271
279
|
localSyncClear: 'Ўчириш',
|
|
272
|
-
localSyncConfirmClear: 'Бу
|
|
280
|
+
localSyncConfirmClear: 'Бу локал версия ўчирилсинми?',
|
|
273
281
|
localSyncConfirmYes: 'Ҳа, ўчириш',
|
|
274
282
|
localSyncConfirmNo: 'Бекор қилиш',
|
|
275
283
|
localSyncClose: 'Ёпиш',
|
package/src/utils/idbStore.js
CHANGED
|
@@ -1,13 +1,40 @@
|
|
|
1
1
|
// Minimal Promise-based wrapper over the native indexedDB API — used by
|
|
2
|
-
// ExcelGrid.vue's localSyncKey feature to
|
|
3
|
-
// `data` array
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// ExcelGrid.vue's localSyncKey feature to keep MULTIPLE local versions of the
|
|
3
|
+
// v-model `data` array, saved on page-hide (not per-edit — see
|
|
4
|
+
// ExcelGrid.vue's _flushLocalVersionOnHide()). No third-party dependency (e.g.
|
|
5
|
+
// idb/localforage) — the API surface needed here is small enough that a raw
|
|
6
|
+
// wrapper is simpler than a new dependency, matching this project's otherwise
|
|
7
|
+
// minimal dependency list.
|
|
8
|
+
//
|
|
9
|
+
// Schema (v2 — breaking change from v1's single-snapshot-per-key 'snapshots'
|
|
10
|
+
// store, which is dropped on upgrade with no migration):
|
|
11
|
+
// DB 'vue2-excel-grid-sync', store 'versions', keyPath 'id', plus an index
|
|
12
|
+
// 'by_localSyncKey' so all versions for one key can be listed without a full
|
|
13
|
+
// table scan. Record shape:
|
|
14
|
+
// { id, localSyncKey, data, hash, createdAt, updatedAt }
|
|
15
|
+
// `id` is `${localSyncKey}-YYYY-MM-DD-HHmmss`, minted once when a version is
|
|
16
|
+
// first created. `hash` is a content hash (src/utils/dataHash.js) used to
|
|
17
|
+
// detect "this is the same content as an existing version" — in that case
|
|
18
|
+
// the existing record's `updatedAt` is bumped instead of creating a new id
|
|
19
|
+
// ("ustiga yoziladi, alohida versiya qilinmaydi").
|
|
8
20
|
const DB_NAME = 'vue2-excel-grid-sync'
|
|
9
|
-
const STORE_NAME = '
|
|
10
|
-
const
|
|
21
|
+
const STORE_NAME = 'versions'
|
|
22
|
+
const OLD_STORE_NAME = 'snapshots' // v1 — dropped on upgrade, not migrated
|
|
23
|
+
const INDEX_BY_KEY = 'by_localSyncKey'
|
|
24
|
+
const DB_VERSION = 2
|
|
25
|
+
|
|
26
|
+
function pad2(n) { return n < 10 ? '0' + n : '' + n }
|
|
27
|
+
|
|
28
|
+
// `${localSyncKey}-YYYY-MM-DD-HHmmss`, minute/second precision so two flushes
|
|
29
|
+
// within the same second (rare, but possible from multiple rapid tab-hides)
|
|
30
|
+
// collide on id rather than silently sorting oddly — callers treat that as
|
|
31
|
+
// "overwrite this version", which is correct: it's still the same visit.
|
|
32
|
+
export function formatVersionId(localSyncKey, date) {
|
|
33
|
+
var d = date || new Date()
|
|
34
|
+
return localSyncKey + '-' +
|
|
35
|
+
d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + '-' +
|
|
36
|
+
pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds())
|
|
37
|
+
}
|
|
11
38
|
|
|
12
39
|
function openDb() {
|
|
13
40
|
return new Promise((resolve, reject) => {
|
|
@@ -18,8 +45,12 @@ function openDb() {
|
|
|
18
45
|
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
|
19
46
|
req.onupgradeneeded = () => {
|
|
20
47
|
const db = req.result
|
|
48
|
+
if (db.objectStoreNames.contains(OLD_STORE_NAME)) {
|
|
49
|
+
db.deleteObjectStore(OLD_STORE_NAME) // v1 -> v2: breaking, no migration
|
|
50
|
+
}
|
|
21
51
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
22
|
-
db.createObjectStore(STORE_NAME, { keyPath: '
|
|
52
|
+
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
|
53
|
+
store.createIndex(INDEX_BY_KEY, 'localSyncKey', { unique: false })
|
|
23
54
|
}
|
|
24
55
|
}
|
|
25
56
|
req.onsuccess = () => resolve(req.result)
|
|
@@ -27,33 +58,104 @@ function openDb() {
|
|
|
27
58
|
})
|
|
28
59
|
}
|
|
29
60
|
|
|
30
|
-
export async function
|
|
61
|
+
export async function listVersions(localSyncKey) {
|
|
31
62
|
const db = await openDb()
|
|
32
63
|
return new Promise((resolve, reject) => {
|
|
33
|
-
const tx = db.transaction(STORE_NAME, '
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
64
|
+
const tx = db.transaction(STORE_NAME, 'readonly')
|
|
65
|
+
const idx = tx.objectStore(STORE_NAME).index(INDEX_BY_KEY)
|
|
66
|
+
// getAll() accepts a bare key directly (no IDBKeyRange needed) — keeps this
|
|
67
|
+
// module (and its test mock) free of a dependency on the IDBKeyRange global.
|
|
68
|
+
const req = idx.getAll(localSyncKey)
|
|
69
|
+
req.onsuccess = () => {
|
|
70
|
+
db.close()
|
|
71
|
+
const rows = req.result || []
|
|
72
|
+
rows.sort((a, b) => b.createdAt - a.createdAt) // newest first
|
|
73
|
+
resolve(rows)
|
|
74
|
+
}
|
|
75
|
+
req.onerror = () => { db.close(); reject(req.error) }
|
|
38
76
|
})
|
|
39
77
|
}
|
|
40
78
|
|
|
41
|
-
export async function
|
|
79
|
+
export async function getVersion(id) {
|
|
42
80
|
const db = await openDb()
|
|
43
81
|
return new Promise((resolve, reject) => {
|
|
44
82
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
|
45
|
-
const req = tx.objectStore(STORE_NAME).get(
|
|
83
|
+
const req = tx.objectStore(STORE_NAME).get(id)
|
|
46
84
|
req.onsuccess = () => { db.close(); resolve(req.result || null) }
|
|
47
85
|
req.onerror = () => { db.close(); reject(req.error) }
|
|
48
86
|
})
|
|
49
87
|
}
|
|
50
88
|
|
|
51
|
-
export async function
|
|
52
|
-
const
|
|
89
|
+
export async function findVersionByHash(localSyncKey, hash) {
|
|
90
|
+
const versions = await listVersions(localSyncKey)
|
|
91
|
+
return versions.find(v => v.hash === hash) || null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function putVersion(db, record) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
97
|
+
tx.objectStore(STORE_NAME).put(record)
|
|
98
|
+
tx.oncomplete = () => { db.close(); resolve(record) }
|
|
99
|
+
tx.onerror = () => { db.close(); reject(tx.error) }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function deleteVersionOnDb(db, id) {
|
|
53
104
|
return new Promise((resolve, reject) => {
|
|
54
105
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
55
|
-
tx.objectStore(STORE_NAME).delete(
|
|
106
|
+
tx.objectStore(STORE_NAME).delete(id)
|
|
56
107
|
tx.oncomplete = () => { db.close(); resolve() }
|
|
57
108
|
tx.onerror = () => { db.close(); reject(tx.error) }
|
|
58
109
|
})
|
|
59
110
|
}
|
|
111
|
+
|
|
112
|
+
export async function deleteVersion(id) {
|
|
113
|
+
const db = await openDb()
|
|
114
|
+
return deleteVersionOnDb(db, id)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The core write path: given a content hash already computed for `data`
|
|
118
|
+
// (src/utils/dataHash.js — main thread or Worker, ExcelGrid.vue's concern, not
|
|
119
|
+
// this module's), either touches an existing version with the same hash
|
|
120
|
+
// (updatedAt bump only — "ustiga yoziladi") or creates a brand-new one and
|
|
121
|
+
// evicts the oldest version(s) past `maxVersions` (FIFO). Centralized here
|
|
122
|
+
// rather than in ExcelGrid.vue so the dedup+eviction policy lives with the
|
|
123
|
+
// storage layer it operates on.
|
|
124
|
+
export async function saveOrTouchVersion(localSyncKey, data, hash, maxVersions) {
|
|
125
|
+
const existing = await findVersionByHash(localSyncKey, hash)
|
|
126
|
+
const now = Date.now()
|
|
127
|
+
|
|
128
|
+
if (existing) {
|
|
129
|
+
const db = await openDb()
|
|
130
|
+
const touched = { ...existing, updatedAt: now }
|
|
131
|
+
await putVersion(db, touched)
|
|
132
|
+
return { version: touched, overwritten: true }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Disambiguate the rare case of two DIFFERENT contents saved within the
|
|
136
|
+
// same wall-clock second (the id's precision) — not reachable from real
|
|
137
|
+
// page-hide events in practice, but possible via rapid saveLocalVersionNow()
|
|
138
|
+
// calls. Without this, the second save's id would collide with the first
|
|
139
|
+
// and silently overwrite it via put() — the same-hash "touch" case is
|
|
140
|
+
// already handled above; this is specifically the different-hash case.
|
|
141
|
+
const existingIds = new Set((await listVersions(localSyncKey)).map(v => v.id))
|
|
142
|
+
let id = formatVersionId(localSyncKey, new Date(now))
|
|
143
|
+
for (let suffix = 2; existingIds.has(id); suffix++) {
|
|
144
|
+
id = formatVersionId(localSyncKey, new Date(now)) + '-' + suffix
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const record = { id, localSyncKey, data, hash, createdAt: now, updatedAt: now }
|
|
148
|
+
const db = await openDb()
|
|
149
|
+
await putVersion(db, record)
|
|
150
|
+
|
|
151
|
+
if (maxVersions > 0) {
|
|
152
|
+
const versions = await listVersions(localSyncKey) // newest first
|
|
153
|
+
const excess = versions.slice(maxVersions)
|
|
154
|
+
for (const stale of excess) {
|
|
155
|
+
const staleDb = await openDb()
|
|
156
|
+
await deleteVersionOnDb(staleDb, stale.id)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { version: record, overwritten: false }
|
|
161
|
+
}
|