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.
@@ -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
- localSyncTitle: 'Local saved copy',
51
- localSyncEmpty: 'No local copy saved yet',
52
- localSyncSavedAt: 'Last saved',
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 into the local copy',
57
+ localSyncSaveHint: 'Save the current table as a new local version',
56
58
  localSyncRestore: 'Restore',
57
- localSyncRestoreHint: 'Load the local copy back into the table',
59
+ localSyncRestoreHint: 'Restore the selected local version into the table',
58
60
  localSyncClear: 'Delete',
59
- localSyncConfirmClear: 'Delete this local copy?',
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
- localSyncTitle: 'Локально сохранённая копия',
122
- localSyncEmpty: 'Локальная копия пока не сохранена',
123
- localSyncSavedAt: 'Последнее сохранение',
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
- localSyncTitle: 'Lokal saqlangan nusxa',
193
- localSyncEmpty: 'Hali lokal saqlangan nusxa yo\'q',
194
- localSyncSavedAt: 'Oxirgi saqlangan',
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 nusxaga saqlash',
203
+ localSyncSaveHint: 'Joriy jadvalni yangi lokal versiya sifatida saqlash',
198
204
  localSyncRestore: 'Tiklash',
199
- localSyncRestoreHint: 'Lokal nusxani jadvalga qaytarish',
205
+ localSyncRestoreHint: 'Tanlangan lokal versiyani jadvalga qaytarish',
200
206
  localSyncClear: 'O\'chirish',
201
- localSyncConfirmClear: 'Bu lokal nusxa o\'chirilsinmi?',
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
- localSyncTitle: 'Лакал сақланган нусха',
264
- localSyncEmpty: 'Ҳали лакал сақланган нусха йўқ',
265
- localSyncSavedAt: 'Охирги сақланган',
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: 'Ёпиш',
@@ -1,13 +1,40 @@
1
1
  // Minimal Promise-based wrapper over the native indexedDB API — used by
2
- // ExcelGrid.vue's localSyncKey feature to autosave/restore the v-model
3
- // `data` array locally in the browser. One database, one object store,
4
- // records keyed by the host-supplied localSyncKey string. No third-party
5
- // dependency (e.g. idb/localforage) — the API surface needed here (put/get/
6
- // delete by key) is small enough that a raw wrapper is simpler than a new
7
- // dependency, matching this project's otherwise minimal dependency list.
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 = 'snapshots'
10
- const DB_VERSION = 1
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: 'key' })
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 saveSnapshot(key, data) {
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, 'readwrite')
34
- const updatedAt = Date.now()
35
- tx.objectStore(STORE_NAME).put({ key, data, updatedAt })
36
- tx.oncomplete = () => { db.close(); resolve({ key, updatedAt }) }
37
- tx.onerror = () => { db.close(); reject(tx.error) }
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 loadSnapshot(key) {
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(key)
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 deleteSnapshot(key) {
52
- const db = await openDb()
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(key)
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
+ }