@things-factory/figure-ui 10.1.243 → 10.1.245
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/client/graphql/index.ts +15 -0
- package/client/modeller/editor.ts +48 -2
- package/client/modeller/figure-save-conflict.ts +73 -0
- package/client/modeller/figure-section-editor.ts +3 -0
- package/client/modeller/product-session.ts +60 -6
- package/client/modeller/save-conflict.ts +91 -0
- package/client/pages/figure-list-page.ts +15 -5
- package/client/pages/figure-modeller-page.ts +27 -9
- package/dist-client/graphql/index.d.ts +2 -0
- package/dist-client/graphql/index.js +15 -0
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +7 -0
- package/dist-client/modeller/editor.js +55 -2
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-save-conflict.d.ts +19 -0
- package/dist-client/modeller/figure-save-conflict.js +76 -0
- package/dist-client/modeller/figure-save-conflict.js.map +1 -0
- package/dist-client/modeller/figure-section-editor.d.ts +2 -0
- package/dist-client/modeller/figure-section-editor.js +8 -0
- package/dist-client/modeller/figure-section-editor.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +234 -1
- package/dist-client/modeller/product-session.js +67 -7
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/modeller/save-conflict.d.ts +12 -0
- package/dist-client/modeller/save-conflict.js +63 -0
- package/dist-client/modeller/save-conflict.js.map +1 -0
- package/dist-client/pages/figure-list-page.d.ts +1 -0
- package/dist-client/pages/figure-list-page.js +12 -3
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +4 -3
- package/dist-client/pages/figure-modeller-page.js +29 -9
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/browser/product-page.check.mjs +176 -0
- package/test/browser/product-page.js +4 -1
- package/test/browser/section.check.mjs +21 -0
- package/test/product-session.test.ts +73 -6
- package/test/save-conflict.test.ts +41 -0
- package/test/save-landed.test.ts +75 -0
- package/translations/en.json +12 -1
- package/translations/es.json +12 -1
- package/translations/ja.json +12 -1
- package/translations/ko.json +12 -1
- package/translations/ms.json +12 -1
- package/translations/zh.json +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-ui",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.245",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"@operato/slot-layout-editor": "^10.29.58",
|
|
43
43
|
"@operato/styles": "^10.0.0",
|
|
44
44
|
"@operato/utils": "^10.29.43",
|
|
45
|
-
"@things-factory/ai-assistant": "^10.1.
|
|
46
|
-
"@things-factory/figure-service": "^10.1.
|
|
45
|
+
"@things-factory/ai-assistant": "^10.1.245",
|
|
46
|
+
"@things-factory/figure-service": "^10.1.245",
|
|
47
47
|
"@things-factory/figure-viewer": "^10.1.227",
|
|
48
|
-
"@things-factory/setting-base": "^10.1.
|
|
48
|
+
"@things-factory/setting-base": "^10.1.245",
|
|
49
49
|
"three": "^0.185.1"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "f7479631094a1c70b486485b556885b069025f88"
|
|
52
52
|
}
|
|
@@ -104,6 +104,70 @@ describe('a save whose answer never came back', () => {
|
|
|
104
104
|
})
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
+
/*
|
|
108
|
+
Another editor saved the figure first (2026-11-01 audit item 6): the edits stay, what differs is said by part, and the
|
|
109
|
+
ways on are asked -- a copy, the latest in a new tab, or writing over it.
|
|
110
|
+
*/
|
|
111
|
+
describe('a save another editor got in before', () => {
|
|
112
|
+
before(async () => {
|
|
113
|
+
await fresh()
|
|
114
|
+
})
|
|
115
|
+
const conflictOn = async (id, theirs) => {
|
|
116
|
+
await read(id => product.seed({ id, type: id, source: product.boxSource('Mine') }), id)
|
|
117
|
+
await open(id)
|
|
118
|
+
await press('figure-editor >>> figure-parts >>> button[shape]', 'a part added')
|
|
119
|
+
await until(() => product.view().dirty, 'edited')
|
|
120
|
+
await read(({ id, theirs }) => product.seed({ id, type: id, source: product.boxSource(theirs) }), { id, theirs })
|
|
121
|
+
await read(() => product.forget())
|
|
122
|
+
await press('button[save]', 'save')
|
|
123
|
+
await until(() => !!product.deep('figure-save-conflict')[0], 'the conflict asked')
|
|
124
|
+
}
|
|
125
|
+
const differs = () => read(() => [...product.deep('figure-save-conflict >>> ul[differs] li')].map(li => li.textContent.replace(/\s+/g, ' ').trim()))
|
|
126
|
+
|
|
127
|
+
test('the latest in a new tab: the edits stay, unsaved, and the next save asks again', async () => {
|
|
128
|
+
await conflictOn('k1', 'Theirs')
|
|
129
|
+
const added = await read(() => JSON.parse(product.view().source).document.model.nodes.find(n => n.op === 'place@1' && n.id !== 'box').id)
|
|
130
|
+
assert.deepEqual(await differs(), [`figure.label.conflict-mine-only ${added}`, 'figure.text.conflict-other-changed'])
|
|
131
|
+
await read(() => { window.opened = []; window.open = (...a) => (window.opened.push(a[0]), null) })
|
|
132
|
+
await press('figure-save-conflict >>> md-outlined-button[latest]', 'the latest')
|
|
133
|
+
await until(() => !product.deep('figure-save-conflict')[0], 'closed')
|
|
134
|
+
assert.deepEqual(await read(() => window.opened), [await read(() => location.href)])
|
|
135
|
+
const view = await read(() => product.view())
|
|
136
|
+
assert.deepEqual({ id: view.id, dirty: view.dirty }, { id: 'k1', dirty: true })
|
|
137
|
+
assert.equal(JSON.parse((await read(() => product.rows())).find(r => r.id === 'k1').source).name, 'Theirs')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('kept as a copy: a new figure saved by the first save\'s question, the other left as the server holds it', async () => {
|
|
141
|
+
await read(() => product.deep('figure-save-conflict')[0] && product.deep('figure-save-conflict >>> md-text-button[slot=cancel]')[0].click())
|
|
142
|
+
/* Tags changed and not saved go with the copy (2026-11-01 re-check). */
|
|
143
|
+
await change('figure-properties >>> input[placeholder="figure.text.tags-placeholder"]', 'new-tag')
|
|
144
|
+
await read(() => product.forget())
|
|
145
|
+
await press('button[save]', 'save')
|
|
146
|
+
await until(() => !!product.deep('figure-save-conflict')[0], 'the conflict asked again')
|
|
147
|
+
await press('figure-save-conflict >>> md-filled-button[copy]', 'save as a copy')
|
|
148
|
+
await until(() => !!product.deep('figure-first-save')[0], 'the first save asked')
|
|
149
|
+
assert.notEqual(await read(() => product.deep('figure-first-save >>> #type')[0].value), 'k1', 'the identifier of the figure left is not offered')
|
|
150
|
+
await press('figure-first-save >>> md-filled-button[slot=confirm]', 'the first save')
|
|
151
|
+
await until(() => product.view().id && product.view().id !== 'k1' && !product.view().dirty, 'saved as a figure of its own')
|
|
152
|
+
const copy = await read(() => product.view().id)
|
|
153
|
+
await until(c => product.rows().find(r => r.id === c)?.tags?.length, 'the tags written to the copy', copy)
|
|
154
|
+
assert.deepEqual(await callsTo('updateFigure'), [{ id: copy, patch: { tags: ['new-tag'] } }])
|
|
155
|
+
const rows = await read(() => product.rows())
|
|
156
|
+
assert.equal(JSON.parse(rows.find(r => r.id === 'k1').source).name, 'Theirs')
|
|
157
|
+
assert.equal(JSON.parse(rows.find(r => r.id === copy).source).document.model.nodes.filter(n => n.op === 'place@1').length, 2, 'the copy has the edits')
|
|
158
|
+
assert.equal(await read(() => location.pathname.endsWith(`/figure-modeller/${product.view().id}`)), true, 'the address is the copy\'s')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('written over, once more agreed: saved over the latest, and nothing else asked', async () => {
|
|
162
|
+
await conflictOn('k2', 'Theirs')
|
|
163
|
+
await press('figure-save-conflict >>> md-text-button[overwrite]', 'overwrite')
|
|
164
|
+
await press('ox-prompt >>> #confirm', 'confirm the overwrite')
|
|
165
|
+
await until(() => !product.view().dirty, 'saved')
|
|
166
|
+
assert.equal(JSON.parse((await read(() => product.rows())).find(r => r.id === 'k2').source).name, 'Mine')
|
|
167
|
+
assert.equal(await read(() => product.view().id), 'k2')
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
107
171
|
describe('a released figure the server makes a draft again', () => {
|
|
108
172
|
before(async () => {
|
|
109
173
|
await fresh()
|
|
@@ -162,6 +226,118 @@ describe('a released figure the server makes a draft again', () => {
|
|
|
162
226
|
assert.equal(await read(() => product.deep('figure-properties >>> input[placeholder="figure.text.category-placeholder"]')[0].value), 'conveyors')
|
|
163
227
|
})
|
|
164
228
|
|
|
229
|
+
/* 2026-11-01 re-check: typed while the save was on its way, the field showed it, the server had the first, and nothing was unsaved. */
|
|
230
|
+
test('tags typed while their save is on its way: shown, still to save, and saved by the next 「저장」', async () => {
|
|
231
|
+
await read(() => product.seed({ id: 't', type: 'tag', source: product.boxSource('T') }))
|
|
232
|
+
await open('t')
|
|
233
|
+
await read(() => {
|
|
234
|
+
window.gate = new Promise(go => (window.letGo = go))
|
|
235
|
+
product.answer('updateFigure', async v => (await window.gate, product.server('updateFigure', v)))
|
|
236
|
+
})
|
|
237
|
+
const tags = 'figure-properties >>> input[placeholder="figure.text.tags-placeholder"]'
|
|
238
|
+
await change(tags, 'first')
|
|
239
|
+
await press('button[save]', 'save')
|
|
240
|
+
await until(() => product.page().session.read().saving, 'saving')
|
|
241
|
+
await change(tags, 'second')
|
|
242
|
+
await read(() => { window.letGo(); product.answer('updateFigure') })
|
|
243
|
+
await until(() => !product.page().session.read().saving, 'saved')
|
|
244
|
+
assert.equal((await read(() => product.rows())).find(r => r.id === 't').tags.join(), 'first')
|
|
245
|
+
assert.equal((await read(() => product.view())).dirty, true, 'the second is still to save')
|
|
246
|
+
assert.equal(await read(s => product.deep(s)[0].value, tags), 'second')
|
|
247
|
+
await press('button[save]', 'save')
|
|
248
|
+
await until(() => !product.view().dirty, 'saved again')
|
|
249
|
+
assert.equal((await read(() => product.rows())).find(r => r.id === 't').tags.join(), 'second')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
/* 2026-11-01 second re-check: put back to the old tags while the save wrote others, the screen said old, the server the others, nothing unsaved. */
|
|
253
|
+
test('tags put back to the old ones while their save is on its way: still to save, and the next 「저장」 writes them', async () => {
|
|
254
|
+
await read(() => product.seed({ id: 'u', type: 'untag', tags: ['old'], source: product.boxSource('U') }))
|
|
255
|
+
await open('u')
|
|
256
|
+
await read(() => {
|
|
257
|
+
window.gate = new Promise(go => (window.letGo = go))
|
|
258
|
+
product.answer('updateFigure', async v => (await window.gate, product.server('updateFigure', v)))
|
|
259
|
+
})
|
|
260
|
+
const tags = 'figure-properties >>> input[placeholder="figure.text.tags-placeholder"]'
|
|
261
|
+
await until(s => product.deep(s)[0]?.value === 'old', 'the tags read', tags)
|
|
262
|
+
await change(tags, 'first')
|
|
263
|
+
await press('button[save]', 'save')
|
|
264
|
+
await until(() => product.page().session.read().saving, 'saving')
|
|
265
|
+
await change(tags, 'old')
|
|
266
|
+
await read(() => { window.letGo(); product.answer('updateFigure') })
|
|
267
|
+
await until(() => !product.page().session.read().saving, 'saved')
|
|
268
|
+
assert.equal((await read(() => product.rows())).find(r => r.id === 'u').tags.join(), 'first')
|
|
269
|
+
assert.equal((await read(() => product.view())).dirty, true, 'the old tags are still to save')
|
|
270
|
+
assert.equal(await read(s => product.deep(s)[0].value, tags), 'old')
|
|
271
|
+
/* Compared with what the row holds now: the ones written are no change, the old ones are. */
|
|
272
|
+
await change(tags, 'first')
|
|
273
|
+
await until(() => !product.view().dirty, 'the row read again: first is what it holds')
|
|
274
|
+
await change(tags, 'old')
|
|
275
|
+
assert.equal((await read(() => product.view())).dirty, true)
|
|
276
|
+
await press('button[save]', 'save')
|
|
277
|
+
await until(() => !product.view().dirty, 'saved again')
|
|
278
|
+
assert.equal((await read(() => product.rows())).find(r => r.id === 'u').tags.join(), 'old')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
/* 2026-11-01 third re-check: typed between the save's answer and the row read again, a value was let go, then written over. */
|
|
282
|
+
test('tags typed after the save answered and before the row is read again: compared with what was written, and kept', async () => {
|
|
283
|
+
await read(() => product.seed({ id: 'v', type: 'vtag', tags: ['old'], source: product.boxSource('V') }))
|
|
284
|
+
await open('v')
|
|
285
|
+
const tags = 'figure-properties >>> input[placeholder="figure.text.tags-placeholder"]'
|
|
286
|
+
await until(s => product.deep(s)[0]?.value === 'old', 'the tags read', tags)
|
|
287
|
+
await read(() => {
|
|
288
|
+
window.gate = new Promise(go => (window.letGo = go))
|
|
289
|
+
window.rowGate = new Promise(go => (window.letRowGo = go))
|
|
290
|
+
product.answer('updateFigure', async v => (await window.gate, product.server('updateFigure', v)))
|
|
291
|
+
window.rowAnswered = 0
|
|
292
|
+
product.answer('figure', async v => (await window.rowGate, window.rowAnswered++, product.server('figure', v)))
|
|
293
|
+
})
|
|
294
|
+
await change(tags, 'first')
|
|
295
|
+
await press('button[save]', 'save')
|
|
296
|
+
await until(() => product.page().session.read().saving, 'saving')
|
|
297
|
+
await change(tags, 'second')
|
|
298
|
+
await read(() => window.letGo())
|
|
299
|
+
await until(() => !product.page().session.read().saving, 'the save answered')
|
|
300
|
+
assert.equal((await read(() => product.view())).dirty, true, 'second is still to save')
|
|
301
|
+
/* The row not read again yet. */
|
|
302
|
+
await change(tags, 'old')
|
|
303
|
+
assert.equal((await read(() => product.view())).dirty, true, 'old is a change from first, which the row holds now')
|
|
304
|
+
await read(() => { window.letRowGo(); product.answer('figure'); product.answer('updateFigure') })
|
|
305
|
+
await until(() => window.rowAnswered > 0, 'the row read again')
|
|
306
|
+
await read(() => new Promise(go => requestAnimationFrame(() => setTimeout(go, 50))))
|
|
307
|
+
assert.equal(await read(s => product.deep(s)[0].value, tags), 'old', 'not written over by the reading')
|
|
308
|
+
await press('button[save]', 'save')
|
|
309
|
+
await until(() => !product.view().dirty, 'saved again')
|
|
310
|
+
assert.equal((await read(() => product.rows())).find(r => r.id === 'v').tags.join(), 'old')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('a reading of the row that comes back after a later one is not the row now', async () => {
|
|
314
|
+
await read(() => product.seed({ id: 'w', type: 'wtag', tags: ['old'], source: product.boxSource('W') }))
|
|
315
|
+
await open('w')
|
|
316
|
+
const tags = 'figure-properties >>> input[placeholder="figure.text.tags-placeholder"]'
|
|
317
|
+
await until(s => product.deep(s)[0]?.value === 'old', 'the tags read', tags)
|
|
318
|
+
await read(() => {
|
|
319
|
+
window.firstReading = new Promise(go => (window.letFirstGo = go))
|
|
320
|
+
window.readings = 0
|
|
321
|
+
/* The first reading after a save is taken as the server holds the row then, and answers late. */
|
|
322
|
+
product.answer('figure', async v => {
|
|
323
|
+
const answer = product.server('figure', v)
|
|
324
|
+
if (window.readings++ === 0) await window.firstReading
|
|
325
|
+
return answer
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
await change(tags, 'a')
|
|
329
|
+
await press('button[save]', 'save')
|
|
330
|
+
await until(() => !product.view().dirty && window.readings === 1, 'saved, its reading on its way')
|
|
331
|
+
await change(tags, 'b')
|
|
332
|
+
await press('button[save]', 'save')
|
|
333
|
+
await until(() => !product.view().dirty && window.readings === 2, 'saved again, read')
|
|
334
|
+
await read(() => { window.letFirstGo(); product.answer('figure') })
|
|
335
|
+
await read(() => new Promise(go => requestAnimationFrame(() => setTimeout(go, 50))))
|
|
336
|
+
assert.equal(await read(s => product.deep(s)[0].value, tags), 'b')
|
|
337
|
+
await change(tags, 'b')
|
|
338
|
+
assert.equal((await read(() => product.view())).dirty, false, 'b is what the row holds')
|
|
339
|
+
})
|
|
340
|
+
|
|
165
341
|
test('tags changed and the figure left without saving: the row keeps what it had, and undo is the figure\'s', async () => {
|
|
166
342
|
await open('n')
|
|
167
343
|
await read(() => product.forget())
|
|
@@ -81,7 +81,10 @@ function row(id) {
|
|
|
81
81
|
const answers = {
|
|
82
82
|
figure: ({ id }) => ({ __typename: 'Figure', properties: null, tags: null, category: null, description: null, score: null, triangles: null, groups: null, thumbnailUpdatedAt: null, updatedAt: null, updater: null, thumbnailHash: null, ...row(id) }),
|
|
83
83
|
figureTypeNames: () => [...rows.values()].map(r => r.type),
|
|
84
|
-
saveFigure: async ({ id, source, type, creationId, thumbnail, proposalKeys }) => {
|
|
84
|
+
saveFigure: async ({ id, source, expectedSource, type, creationId, thumbnail, proposalKeys }) => {
|
|
85
|
+
/* Written only over the source the editor opened, as figure-write.ts writes. */
|
|
86
|
+
if (id && expectedSource !== undefined && expectedSource !== row(id).source)
|
|
87
|
+
throw Object.assign(Error('changed elsewhere'), { graphQLErrors: [{ message: 'changed elsewhere', extensions: { code: 'FIGURE_SOURCE_CONFLICT' } }] })
|
|
85
88
|
const saved = id ? row(id) : { id: creationId ?? `made-${++made}`, namespace: null, type: type ?? `figure-${made}`, state: 'draft', version: 0 }
|
|
86
89
|
/* The save's record of the proposals its source holds, as the server keeps it (figure-proposal.ts). */
|
|
87
90
|
for (const key of proposalKeys ?? []) if (!records.some(r => r.proposalKey === key && r.figureId === saved.id)) records.push({ proposalKey: key, figureId: saved.id })
|
|
@@ -248,6 +248,27 @@ describe('the section editor on a drawn section: typed, previewed, applied or le
|
|
|
248
248
|
await until(() => probe.drawnShape('wedge').section.points.length === 3, 'the solid drawn with three points')
|
|
249
249
|
})
|
|
250
250
|
|
|
251
|
+
/* A page left keeps the modeller, and this editor, on the window (2026-11-01 audit item 1, found again in the re-check). */
|
|
252
|
+
test('on a page left, Delete and Ctrl+Z are not the editor\'s: no point deleted, none brought back', async () => {
|
|
253
|
+
await press('g[point="p3"] circle', { within: 'figure-section-editor' })
|
|
254
|
+
await read(() => (findDeep('figure-editor').active = false))
|
|
255
|
+
await page().keyboard.press('Delete')
|
|
256
|
+
await page().keyboard.down('Control')
|
|
257
|
+
await page().keyboard.press('KeyZ')
|
|
258
|
+
await page().keyboard.up('Control')
|
|
259
|
+
await sleep(100)
|
|
260
|
+
assert.equal((await editorNow()).points, 3)
|
|
261
|
+
await read(() => (findDeep('figure-editor').active = true))
|
|
262
|
+
await page().keyboard.down('Control')
|
|
263
|
+
await page().keyboard.press('KeyZ')
|
|
264
|
+
await page().keyboard.up('Control')
|
|
265
|
+
await until(() => probe.sectionEditor().points === 4, 'in front again, Ctrl+Z is the editor\'s')
|
|
266
|
+
/* Left as the test before left it, with its change: the next asks about it. */
|
|
267
|
+
await press('g[point="p4"] circle', { within: 'figure-section-editor' })
|
|
268
|
+
await page().keyboard.press('Delete')
|
|
269
|
+
await until(() => probe.sectionEditor().points === 3, 'p4 deleted again')
|
|
270
|
+
})
|
|
271
|
+
|
|
251
272
|
test('picking another part with changes keeps the part until the editor is closed: asked, let go, then the pick goes on', async () => {
|
|
252
273
|
const start = await state()
|
|
253
274
|
const at = await read(() => probe.row('block'))
|
|
@@ -187,16 +187,83 @@ test('the source and the catalogue: the source first; a row refused keeps the ca
|
|
|
187
187
|
s.editCatalog({ type: 'door' }, { type: 'door' })
|
|
188
188
|
assert.equal(s.read().dirty, false, 'the source was saved')
|
|
189
189
|
})
|
|
190
|
-
test('while
|
|
190
|
+
test('a catalogue field changed while its save is on the way: shown, and held for the next save', async () => {
|
|
191
191
|
const writing = deferred<unknown>()
|
|
192
|
-
const
|
|
192
|
+
const sent: object[] = []
|
|
193
|
+
const s = createFigureProductSession({ ...store, updateCatalog: (_id: string, patch: object) => (sent.push(patch), writing.promise) })
|
|
193
194
|
await s.open('A')
|
|
194
|
-
s.editCatalog({ tags: ['
|
|
195
|
+
s.editCatalog({ tags: ['first'], category: 'C' }, {})
|
|
195
196
|
const saving = s.save()
|
|
196
|
-
|
|
197
|
+
s.editCatalog({ tags: ['second'] }, {})
|
|
198
|
+
writing.resolve({})
|
|
199
|
+
await saving
|
|
200
|
+
assert.deepEqual(sent, [{ tags: ['first'], category: 'C' }])
|
|
201
|
+
assert.deepEqual(s.read().catalog, { tags: ['second'] }, 'the category saved; the tags typed since still to save')
|
|
197
202
|
assert.equal(s.read().dirty, true)
|
|
203
|
+
/* Changed back to what was sent while it was on its way: nothing left to save. */
|
|
204
|
+
const again = deferred<unknown>()
|
|
205
|
+
const t = createFigureProductSession({ ...store, updateCatalog: () => again.promise })
|
|
206
|
+
await t.open('A')
|
|
207
|
+
t.editCatalog({ tags: ['first'] }, {})
|
|
208
|
+
const second = t.save()
|
|
209
|
+
t.editCatalog({ tags: ['other'] }, {})
|
|
210
|
+
t.editCatalog({ tags: ['first'] }, {})
|
|
211
|
+
again.resolve({})
|
|
212
|
+
await second
|
|
213
|
+
assert.equal(t.read().dirty, false)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
/* 2026-11-01 second re-check: put back to the old value while the save writes another, it was let go -- the row kept the other. */
|
|
217
|
+
test('a field put back to its old value while a save writes another is held: the old value is a change once that save lands', async () => {
|
|
218
|
+
const writing = deferred<unknown>()
|
|
219
|
+
const s = createFigureProductSession({ ...store, updateCatalog: () => writing.promise })
|
|
220
|
+
await s.open('A')
|
|
221
|
+
s.editCatalog({ tags: ['first'] }, { tags: ['old'] })
|
|
222
|
+
const saving = s.save()
|
|
223
|
+
s.editCatalog({ tags: ['old'] }, { tags: ['old'] })
|
|
198
224
|
writing.resolve({})
|
|
199
225
|
await saving
|
|
200
|
-
assert.deepEqual(s.read().catalog, {})
|
|
201
|
-
assert.equal(s.read().dirty,
|
|
226
|
+
assert.deepEqual(s.read().catalog, { tags: ['old'] })
|
|
227
|
+
assert.equal(s.read().dirty, true)
|
|
228
|
+
/* While the source is being written, before the row: the same. */
|
|
229
|
+
const source = deferred<FigureProductRecord>()
|
|
230
|
+
const t = createFigureProductSession({ ...store, save: () => source.promise })
|
|
231
|
+
await t.open('A')
|
|
232
|
+
t.command({ kind: 'rename', name: 'B' } as never)
|
|
233
|
+
t.editCatalog({ tags: ['first'] }, { tags: ['old'] })
|
|
234
|
+
const both = t.save()
|
|
235
|
+
t.editCatalog({ tags: ['old'] }, { tags: ['old'] })
|
|
236
|
+
source.resolve({ id: 'A', source: t.source() })
|
|
237
|
+
await both
|
|
238
|
+
assert.deepEqual(t.read().catalog, { tags: ['old'] })
|
|
239
|
+
/* Typed again as sent, and the save refused: still held, nothing lost. */
|
|
240
|
+
const refused = deferred<unknown>()
|
|
241
|
+
const u = createFigureProductSession({ ...store, updateCatalog: () => refused.promise })
|
|
242
|
+
await u.open('A')
|
|
243
|
+
u.editCatalog({ tags: ['first'] }, { tags: ['old'] })
|
|
244
|
+
const failing = u.save()
|
|
245
|
+
u.editCatalog({ tags: ['first'] }, { tags: ['old'] })
|
|
246
|
+
refused.reject(Error('refused'))
|
|
247
|
+
await assert.rejects(failing)
|
|
248
|
+
assert.deepEqual(u.read().catalog, { tags: ['first'] })
|
|
249
|
+
/* After a save lands, back to the old value is no change. */
|
|
250
|
+
s.editCatalog({ tags: ['old'] }, { tags: ['first'] })
|
|
251
|
+
assert.deepEqual(s.read().catalog, { tags: ['old'] }, 'compared with what the row holds now')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
/* 2026-11-01 third re-check: what a save wrote is told the moment it answers, for a screen to compare with before it reads the row. */
|
|
255
|
+
test('what the last save wrote of the catalogue is told; a save that writes none tells none', async () => {
|
|
256
|
+
const s = createFigureProductSession(store)
|
|
257
|
+
await s.open('A')
|
|
258
|
+
s.editCatalog({ tags: ['first'], category: 'C' }, { tags: ['old'] })
|
|
259
|
+
await s.save()
|
|
260
|
+
assert.deepEqual(s.read().catalogWritten, { tags: ['first'], category: 'C' })
|
|
261
|
+
s.command({ kind: 'rename', name: 'B' } as never)
|
|
262
|
+
await s.save()
|
|
263
|
+
assert.deepEqual(s.read().catalogWritten, {}, 'a later save of the source alone')
|
|
264
|
+
const refused = createFigureProductSession({ ...store, updateCatalog: async () => Promise.reject(Error('refused')) })
|
|
265
|
+
await refused.open('A')
|
|
266
|
+
refused.editCatalog({ tags: ['x'] }, {})
|
|
267
|
+
await assert.rejects(refused.save())
|
|
268
|
+
assert.deepEqual(refused.read().catalogWritten, {})
|
|
202
269
|
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What differs between the edits on screen and the server's latest when a save is refused for a conflict.
|
|
3
|
+
*/
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { addFigureBox, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
|
|
7
|
+
|
|
8
|
+
import { saveConflictChangesOf } from '../client/modeller/save-conflict.ts'
|
|
9
|
+
|
|
10
|
+
const base = (lid = 10) =>
|
|
11
|
+
addFigureBox(addFigureBox(createFigureAsset('Gate'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }), {
|
|
12
|
+
id: 'lid',
|
|
13
|
+
size: { x: 100, y: lid, z: 100 },
|
|
14
|
+
position: { x: 0, y: 105, z: 0 }
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('the same figure: nothing differs', () => {
|
|
18
|
+
assert.deepEqual(saveConflictChangesOf(base(), base()), { changed: [], mineOnly: [], theirsOnly: [], otherChanged: false })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('a part made differently is named, and only it', () => {
|
|
22
|
+
const theirs = base(30)
|
|
23
|
+
assert.deepEqual(saveConflictChangesOf(base(), theirs), { changed: ['lid'], mineOnly: [], theirsOnly: [], otherChanged: false })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('a part on one side only is named on its side', () => {
|
|
27
|
+
const mine = addFigureBox(base(), { id: 'arm', size: { x: 10, y: 10, z: 10 }, position: { x: 0, y: 0, z: 0 } })
|
|
28
|
+
const theirs = addFigureBox(base(), { id: 'leg', size: { x: 10, y: 10, z: 10 }, position: { x: 0, y: 0, z: 0 } })
|
|
29
|
+
assert.deepEqual(saveConflictChangesOf(mine, theirs), { changed: [], mineOnly: ['arm'], theirsOnly: ['leg'], otherChanged: false })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('the name differs: not a part, said as something else', () => {
|
|
33
|
+
const theirs = applyFigureAuthoring(base(), { kind: 'rename', name: 'Gate 2' } as never)
|
|
34
|
+
assert.deepEqual(saveConflictChangesOf(base(), theirs), { changed: [], mineOnly: [], theirsOnly: [], otherChanged: true })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('a part\'s look differs: the part is named, not something else', () => {
|
|
38
|
+
const theirs = base() as unknown as { appearance: { target: string; material: unknown }[] }
|
|
39
|
+
theirs.appearance = theirs.appearance.map(look => (look.target === 'lid' ? { ...look, material: { token: 'palette.secondary' } } : look))
|
|
40
|
+
assert.deepEqual(saveConflictChangesOf(base(), theirs as never), { changed: ['lid'], mineOnly: [], theirsOnly: [], otherChanged: false })
|
|
41
|
+
})
|
package/test/save-landed.test.ts
CHANGED
|
@@ -214,3 +214,78 @@ test('the answer lost after the picture was written: read back by its hash, it w
|
|
|
214
214
|
assert.equal(session.read().dirty, false, 'source and picture both landed')
|
|
215
215
|
assert.equal(await thumbnailHashOf('not a picture'), undefined)
|
|
216
216
|
})
|
|
217
|
+
|
|
218
|
+
/*
|
|
219
|
+
A real conflict -- another editor saved first -- leaves the edits here, with the server's latest kept to compare with
|
|
220
|
+
and, only when the person says so, to write over or to leave as it is by keeping the edits as a copy (2026-11-01 audit
|
|
221
|
+
item 6).
|
|
222
|
+
*/
|
|
223
|
+
test('a conflict keeps the latest; written over, the next save expects it; another change meanwhile is a conflict again', async () => {
|
|
224
|
+
const s = server()
|
|
225
|
+
const session = createFigureProductSession(s.store)
|
|
226
|
+
await session.open('a')
|
|
227
|
+
session.command({ kind: 'rename', name: 'Mine' } as never)
|
|
228
|
+
const theirs = serializeFigureAsset(addFigureBox(createFigureAsset('Theirs'), { id: 'body', size: { x: 50, y: 50, z: 50 }, position: { x: 0, y: 25, z: 0 } }))
|
|
229
|
+
s.setRow(theirs)
|
|
230
|
+
await assert.rejects(session.save(), { code: 'FIGURE_SOURCE_CONFLICT' })
|
|
231
|
+
assert.deepEqual(session.read().conflict, { latest: theirs })
|
|
232
|
+
assert.equal(session.read().dirty, true)
|
|
233
|
+
assert.equal(session.read().name, 'Mine', 'the edits stay')
|
|
234
|
+
/* Still the other's: saving again is the same conflict. */
|
|
235
|
+
await assert.rejects(session.save(), { code: 'FIGURE_SOURCE_CONFLICT' })
|
|
236
|
+
session.overwriteConflict()
|
|
237
|
+
assert.equal(session.read().conflict, undefined)
|
|
238
|
+
const third = serializeFigureAsset(createFigureAsset('Third'))
|
|
239
|
+
s.setRow(third)
|
|
240
|
+
await assert.rejects(session.save(), { code: 'FIGURE_SOURCE_CONFLICT' }, 'written over only while the latest is what was read')
|
|
241
|
+
assert.deepEqual(session.read().conflict, { latest: third })
|
|
242
|
+
session.overwriteConflict()
|
|
243
|
+
await session.save()
|
|
244
|
+
assert.equal(JSON.parse(s.row()).name, 'Mine')
|
|
245
|
+
assert.equal(session.read().conflict, undefined)
|
|
246
|
+
assert.equal(session.read().dirty, false)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('a conflict kept as a copy: a new figure under a new creation id, the edits with it, the other figure untouched', async () => {
|
|
250
|
+
const s = server()
|
|
251
|
+
const saved: { id?: string; creationId?: string; type?: string }[] = []
|
|
252
|
+
const rowWrites: [string, object][] = []
|
|
253
|
+
const store = {
|
|
254
|
+
...s.store,
|
|
255
|
+
save: async (q: { id?: string; source: string; expectedSource?: string; creationId?: string; type?: string }) => (saved.push(q), q.id ? s.store.save(q) : { id: q.creationId!, source: q.source }),
|
|
256
|
+
updateCatalog: async (id: string, patch: object) => (rowWrites.push([id, patch]), {})
|
|
257
|
+
}
|
|
258
|
+
const session = createFigureProductSession(store)
|
|
259
|
+
await session.open('a')
|
|
260
|
+
const opened = session.read().creationId
|
|
261
|
+
assert.throws(() => session.detachAsCopy(), { code: 'SESSION_NO_CONFLICT' }, 'only after a conflict')
|
|
262
|
+
session.command({ kind: 'rename', name: 'Mine' } as never)
|
|
263
|
+
session.editCatalog({ category: 'equipment', tags: ['new-tag'], type: 'gate-2' }, { type: 'gate' })
|
|
264
|
+
const theirs = serializeFigureAsset(createFigureAsset('Theirs'))
|
|
265
|
+
s.setRow(theirs)
|
|
266
|
+
await assert.rejects(session.save(), { code: 'FIGURE_SOURCE_CONFLICT' })
|
|
267
|
+
const copy = session.detachAsCopy()
|
|
268
|
+
assert.deepEqual(copy.catalog, { category: 'equipment', tags: ['new-tag'] }, 'the category and tags go with the copy; its type is asked anew')
|
|
269
|
+
assert.equal(copy.id, undefined)
|
|
270
|
+
assert.notEqual(copy.creationId, opened)
|
|
271
|
+
assert.equal(copy.conflict, undefined)
|
|
272
|
+
assert.equal(copy.name, 'Mine')
|
|
273
|
+
await session.save({ type: 'gate-copy' })
|
|
274
|
+
assert.deepEqual(saved.at(-1), { ...saved.at(-1), id: undefined, creationId: copy.creationId, type: 'gate-copy', expectedSource: undefined })
|
|
275
|
+
assert.equal(session.read().id, copy.creationId)
|
|
276
|
+
assert.deepEqual(rowWrites, [[copy.creationId, { category: 'equipment', tags: ['new-tag'] }]], 'written to the copy once it has a row')
|
|
277
|
+
assert.equal(session.read().dirty, false)
|
|
278
|
+
assert.equal(s.row(), theirs, 'the figure it came from is as the server had it')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('the latest not readable: the conflict stands, and it cannot be written over blind', async () => {
|
|
282
|
+
const s = server()
|
|
283
|
+
const session = createFigureProductSession(s.store)
|
|
284
|
+
await session.open('a')
|
|
285
|
+
session.command({ kind: 'rename', name: 'Mine' } as never)
|
|
286
|
+
s.setRow(serializeFigureAsset(createFigureAsset('Theirs')))
|
|
287
|
+
s.state.loadFails = true
|
|
288
|
+
await assert.rejects(session.save(), { code: 'FIGURE_SOURCE_CONFLICT' })
|
|
289
|
+
assert.deepEqual(session.read().conflict, { latest: undefined })
|
|
290
|
+
assert.throws(() => session.overwriteConflict(), { code: 'SESSION_NO_CONFLICT' })
|
|
291
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -1294,5 +1294,16 @@
|
|
|
1294
1294
|
"figure.text.data-capability-fields": "Data {name} reads itself: {fields}.",
|
|
1295
1295
|
"figure.label.data-binding-resting": "resting",
|
|
1296
1296
|
"figure.text.data-binding-rests": "In {where} the {name} capability does this instead.",
|
|
1297
|
-
"figure.text.data-binding-rests-targets": "In {where} the {name} capability does this instead for: {targets}"
|
|
1297
|
+
"figure.text.data-binding-rests-targets": "In {where} the {name} capability does this instead for: {targets}",
|
|
1298
|
+
"figure.title.save-conflict": "save conflict",
|
|
1299
|
+
"figure.text.save-conflict-desc": "This figure was saved elsewhere first. Your edits are still here on this screen.",
|
|
1300
|
+
"figure.label.conflict-changed": "parts that differ",
|
|
1301
|
+
"figure.label.conflict-mine-only": "parts only in your edits",
|
|
1302
|
+
"figure.label.conflict-theirs-only": "parts only in the latest",
|
|
1303
|
+
"figure.text.conflict-other-changed": "Settings outside the parts (name, inputs, capabilities...) differ too.",
|
|
1304
|
+
"figure.text.conflict-latest-unread": "The latest could not be read, so it was not compared. It cannot be overwritten.",
|
|
1305
|
+
"figure.button.save-as-copy": "save as a copy",
|
|
1306
|
+
"figure.button.open-latest": "open the latest in a new tab",
|
|
1307
|
+
"figure.button.overwrite-latest": "overwrite with my edits",
|
|
1308
|
+
"figure.text.overwrite-latest-warning": "What changed in the latest is replaced by your edits and not kept. Overwrite?"
|
|
1298
1309
|
}
|
package/translations/es.json
CHANGED
|
@@ -1294,5 +1294,16 @@
|
|
|
1294
1294
|
"figure.text.data-capability-fields": "Datos que {name} lee por sí misma: {fields}.",
|
|
1295
1295
|
"figure.label.data-binding-resting": "en pausa",
|
|
1296
1296
|
"figure.text.data-binding-rests": "En {where} la capacidad {name} se encarga en su lugar.",
|
|
1297
|
-
"figure.text.data-binding-rests-targets": "En {where} la capacidad {name} se encarga en su lugar de: {targets}"
|
|
1297
|
+
"figure.text.data-binding-rests-targets": "En {where} la capacidad {name} se encarga en su lugar de: {targets}",
|
|
1298
|
+
"figure.title.save-conflict": "conflicto al guardar",
|
|
1299
|
+
"figure.text.save-conflict-desc": "Esta figura se guardó antes en otro lugar. Tus ediciones siguen en esta pantalla.",
|
|
1300
|
+
"figure.label.conflict-changed": "partes distintas",
|
|
1301
|
+
"figure.label.conflict-mine-only": "partes solo en tus ediciones",
|
|
1302
|
+
"figure.label.conflict-theirs-only": "partes solo en la última versión",
|
|
1303
|
+
"figure.text.conflict-other-changed": "También difieren ajustes fuera de las partes (nombre, entradas, capacidades...).",
|
|
1304
|
+
"figure.text.conflict-latest-unread": "No se pudo leer la última versión, así que no se comparó. No se puede sobrescribir.",
|
|
1305
|
+
"figure.button.save-as-copy": "guardar como copia",
|
|
1306
|
+
"figure.button.open-latest": "abrir la última versión en una pestaña nueva",
|
|
1307
|
+
"figure.button.overwrite-latest": "sobrescribir con mis ediciones",
|
|
1308
|
+
"figure.text.overwrite-latest-warning": "Lo que cambió en la última versión se reemplaza por tus ediciones y no se conserva. ¿Sobrescribir?"
|
|
1298
1309
|
}
|
package/translations/ja.json
CHANGED
|
@@ -1294,5 +1294,16 @@
|
|
|
1294
1294
|
"figure.text.data-capability-fields": "{name} 能力が読むデータ: {fields}。",
|
|
1295
1295
|
"figure.label.data-binding-resting": "休止",
|
|
1296
1296
|
"figure.text.data-binding-rests": "{where} では {name} 能力が代わりに受け持ちます。",
|
|
1297
|
-
"figure.text.data-binding-rests-targets": "{where} では {name} 能力が代わりに受け持ちます: {targets}"
|
|
1297
|
+
"figure.text.data-binding-rests-targets": "{where} では {name} 能力が代わりに受け持ちます: {targets}",
|
|
1298
|
+
"figure.title.save-conflict": "保存の競合",
|
|
1299
|
+
"figure.text.save-conflict-desc": "別の場所でこの Figure が先に保存されました。編集内容はこの画面にそのまま残っています。",
|
|
1300
|
+
"figure.label.conflict-changed": "異なる部品",
|
|
1301
|
+
"figure.label.conflict-mine-only": "自分の編集にだけある部品",
|
|
1302
|
+
"figure.label.conflict-theirs-only": "最新版にだけある部品",
|
|
1303
|
+
"figure.text.conflict-other-changed": "部品以外の設定(名前・入力・能力など)も異なります。",
|
|
1304
|
+
"figure.text.conflict-latest-unread": "最新版を読み込めなかったため比較できませんでした。上書きはできません。",
|
|
1305
|
+
"figure.button.save-as-copy": "コピーとして保存",
|
|
1306
|
+
"figure.button.open-latest": "最新版を新しいタブで開く",
|
|
1307
|
+
"figure.button.overwrite-latest": "自分の編集で上書き",
|
|
1308
|
+
"figure.text.overwrite-latest-warning": "最新版で変更された内容は自分の編集に置き換えられ、残りません。上書きしますか?"
|
|
1298
1309
|
}
|
package/translations/ko.json
CHANGED
|
@@ -1294,5 +1294,16 @@
|
|
|
1294
1294
|
"figure.text.data-capability-fields": "{name} 능력이 읽는 데이터: {fields}.",
|
|
1295
1295
|
"figure.label.data-binding-resting": "쉼",
|
|
1296
1296
|
"figure.text.data-binding-rests": "{where}에서는 {name} 능력이 대신합니다.",
|
|
1297
|
-
"figure.text.data-binding-rests-targets": "{where}에서는 {name} 능력이 대신합니다: {targets}"
|
|
1297
|
+
"figure.text.data-binding-rests-targets": "{where}에서는 {name} 능력이 대신합니다: {targets}",
|
|
1298
|
+
"figure.title.save-conflict": "저장 충돌",
|
|
1299
|
+
"figure.text.save-conflict-desc": "다른 곳에서 이 피규어를 먼저 저장했습니다. 내 편집은 이 화면에 그대로 있습니다.",
|
|
1300
|
+
"figure.label.conflict-changed": "달라진 부품",
|
|
1301
|
+
"figure.label.conflict-mine-only": "내 편집에만 있는 부품",
|
|
1302
|
+
"figure.label.conflict-theirs-only": "최신본에만 있는 부품",
|
|
1303
|
+
"figure.text.conflict-other-changed": "부품 밖의 설정(이름·입력·능력 등)도 다릅니다.",
|
|
1304
|
+
"figure.text.conflict-latest-unread": "최신본을 읽지 못해 비교하지 못했습니다. 덮어쓰기는 할 수 없습니다.",
|
|
1305
|
+
"figure.button.save-as-copy": "사본으로 저장",
|
|
1306
|
+
"figure.button.open-latest": "최신본을 새 탭에서 열기",
|
|
1307
|
+
"figure.button.overwrite-latest": "내 편집으로 덮어쓰기",
|
|
1308
|
+
"figure.text.overwrite-latest-warning": "최신본에서 바뀐 내용은 내 편집으로 바뀌고 남지 않습니다. 덮어쓸까요?"
|
|
1298
1309
|
}
|
package/translations/ms.json
CHANGED
|
@@ -1294,5 +1294,16 @@
|
|
|
1294
1294
|
"figure.text.data-capability-fields": "Data yang {name} baca sendiri: {fields}.",
|
|
1295
1295
|
"figure.label.data-binding-resting": "rehat",
|
|
1296
1296
|
"figure.text.data-binding-rests": "Dalam {where} keupayaan {name} mengambil alih.",
|
|
1297
|
-
"figure.text.data-binding-rests-targets": "Dalam {where} keupayaan {name} mengambil alih: {targets}"
|
|
1297
|
+
"figure.text.data-binding-rests-targets": "Dalam {where} keupayaan {name} mengambil alih: {targets}",
|
|
1298
|
+
"figure.title.save-conflict": "konflik simpanan",
|
|
1299
|
+
"figure.text.save-conflict-desc": "Figura ini telah disimpan di tempat lain dahulu. Suntingan anda masih ada pada skrin ini.",
|
|
1300
|
+
"figure.label.conflict-changed": "bahagian yang berbeza",
|
|
1301
|
+
"figure.label.conflict-mine-only": "bahagian hanya dalam suntingan anda",
|
|
1302
|
+
"figure.label.conflict-theirs-only": "bahagian hanya dalam versi terkini",
|
|
1303
|
+
"figure.text.conflict-other-changed": "Tetapan di luar bahagian (nama, input, keupayaan...) juga berbeza.",
|
|
1304
|
+
"figure.text.conflict-latest-unread": "Versi terkini tidak dapat dibaca, jadi tidak dibandingkan. Ia tidak boleh ditulis ganti.",
|
|
1305
|
+
"figure.button.save-as-copy": "simpan sebagai salinan",
|
|
1306
|
+
"figure.button.open-latest": "buka versi terkini dalam tab baharu",
|
|
1307
|
+
"figure.button.overwrite-latest": "tulis ganti dengan suntingan saya",
|
|
1308
|
+
"figure.text.overwrite-latest-warning": "Perubahan dalam versi terkini akan diganti oleh suntingan anda dan tidak disimpan. Tulis ganti?"
|
|
1298
1309
|
}
|
package/translations/zh.json
CHANGED
|
@@ -1294,5 +1294,16 @@
|
|
|
1294
1294
|
"figure.text.data-capability-fields": "{name} 能力自己读取的数据:{fields}。",
|
|
1295
1295
|
"figure.label.data-binding-resting": "暂停",
|
|
1296
1296
|
"figure.text.data-binding-rests": "在 {where} 中由 {name} 能力代为处理。",
|
|
1297
|
-
"figure.text.data-binding-rests-targets": "在 {where} 中由 {name} 能力代为处理:{targets}"
|
|
1297
|
+
"figure.text.data-binding-rests-targets": "在 {where} 中由 {name} 能力代为处理:{targets}",
|
|
1298
|
+
"figure.title.save-conflict": "保存冲突",
|
|
1299
|
+
"figure.text.save-conflict-desc": "此 Figure 已在别处先行保存。你的编辑仍保留在此画面中。",
|
|
1300
|
+
"figure.label.conflict-changed": "不同的部件",
|
|
1301
|
+
"figure.label.conflict-mine-only": "仅在你的编辑中的部件",
|
|
1302
|
+
"figure.label.conflict-theirs-only": "仅在最新版本中的部件",
|
|
1303
|
+
"figure.text.conflict-other-changed": "部件以外的设置(名称、输入、能力等)也不同。",
|
|
1304
|
+
"figure.text.conflict-latest-unread": "无法读取最新版本,因此未能比较。无法覆盖。",
|
|
1305
|
+
"figure.button.save-as-copy": "另存为副本",
|
|
1306
|
+
"figure.button.open-latest": "在新标签页中打开最新版本",
|
|
1307
|
+
"figure.button.overwrite-latest": "用我的编辑覆盖",
|
|
1308
|
+
"figure.text.overwrite-latest-warning": "最新版本中的更改将被你的编辑替换且不会保留。要覆盖吗?"
|
|
1298
1309
|
}
|