Skip to content

Commit da8df9e

Browse files
authored
Merge pull request #144 from ZenNotes/release/1.1.51
Release 1.1.51 (versionCode 54)
2 parents 96d967a + 8ac8917 commit da8df9e

24 files changed

Lines changed: 583 additions & 69 deletions

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under
1313
`vendor/zennotes/` with their source identity and checksums (`manifest.json`),
1414
and `package-lock.json` pins the complete install. No source checkout is used.
1515
The vendored set is the core release
16-
[core-2.79.0-core.h7896a01910a2ea0f](https://github.com/ZenNotes/zennotes/releases/tag/core-2.79.0-core.h7896a01910a2ea0f)
17-
(desktop commit `47d26827`, clean tree). Run `npm run
16+
[core-2.80.0-core.hc5a4589d040cd0da](https://github.com/ZenNotes/zennotes/releases/tag/core-2.80.0-core.hc5a4589d040cd0da)
17+
(desktop commit `31520814`, clean tree). Run `npm run
1818
boundaries:check` to verify archives, installed versions, singleton
1919
editor/React peers, and imports; it refuses an archive built from a dirty
2020
upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out.

‎android/app/build.gradle‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ android {
1515
applicationId "md.zennotes"
1616
minSdkVersion rootProject.ext.minSdkVersion
1717
targetSdkVersion rootProject.ext.targetSdkVersion
18-
versionCode 53
19-
versionName "1.1.50"
18+
versionCode 54
19+
versionName "1.1.51"
2020
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
2121
aaptOptions {
2222
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

‎package-lock.json‎

Lines changed: 17 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zennotes-android",
33
"private": true,
4-
"version": "1.1.50",
4+
"version": "1.1.51",
55
"type": "module",
66
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
77
"homepage": "https://zennotes.org",
@@ -43,9 +43,9 @@
4343
"@lezer/highlight": "^1.2.5",
4444
"@replit/codemirror-vim": "^6.3.0",
4545
"@xyflow/react": "^12.12.0",
46-
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.79.0-core.h7896a01910a2ea0f.tgz",
47-
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h28492494a5dfa837.tgz",
48-
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h28492494a5dfa837.tgz",
46+
"@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.80.0-core.hc5a4589d040cd0da.tgz",
47+
"@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.80.0-boundaries.hc07ef9e2bef31a64.tgz",
48+
"@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.80.0-boundaries.hc07ef9e2bef31a64.tgz",
4949
"codemirror": "^6.0.1",
5050
"dompurify": "^3.4.17",
5151
"function-plot": "^1.26.0",

‎src/bridge/cloud-sync-client.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,47 @@ it('puts an object again when the store never answered, never when it refused',
350350
}
351351
}
352352
})
353+
354+
it('keeps which fields a 422 refused, so sync sets one path aside and sends the rest (#1004)', async () => {
355+
const refused = 'This path is not eligible for vault sync.'
356+
const requests: string[] = []
357+
const { createCloudSyncClient, cloudSyncPathRefusal, CloudSyncPathRefusedError } = await loadMobileModule([
358+
'./src/bridge/cloud-sync-client.ts', '@zennotes/shared-domain/cloud-sync-refusal'
359+
], {
360+
'@capacitor/core': {
361+
registerPlugin: () => ({}),
362+
CapacitorHttp: { request: async ({ url, method }: { url: string; method: string }) => {
363+
requests.push(`${method} ${new URL(url).pathname}`)
364+
if (url.endsWith('/uploads')) {
365+
return { status: 422, data: { error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' }, errors: { path: [refused] } } }
366+
}
367+
if (requests.length === 1) {
368+
return { status: 422, data: { error: { code: 'VALIDATION_FAILED', message: 'The request data is invalid.' }, errors: { 'mutations.1.path': [refused] } } }
369+
}
370+
return { status: 200, data: { acknowledged: [{ operation_id: 'kept', item_id: 'kept-item', revision: 1, sequence: 3 }], conflicts: [], cursor: 3 } }
371+
} }
372+
}
373+
})
374+
const content = (bytes: number) => ({ encoding: 'base64', data: Buffer.alloc(bytes, 1).toString('base64'), sha256: 'a'.repeat(64), byte_length: bytes, media_type: 'text/markdown' })
375+
const upsert = (operation: string, path: string, bytes = 4) =>
376+
({ type: 'upsert', operation_id: operation, item_id: `${operation}-item`, base_revision: null, path, kind: 'markdown', content: content(bytes) })
377+
const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'refused-path' })
378+
379+
// One request: the transport keeps Cloud's `errors`, so the refusal names the path.
380+
const inline = { mutations: [upsert('kept', 'inbox/Note.md'), upsert('agent', '.zennotes/agent-edits/inbox/Note.md.json')] }
381+
const error = await client.mutate('vault-1', inline).catch((caught: unknown) => caught)
382+
assert.equal(error.status, 422)
383+
assert.equal(error.code, 'VALIDATION_FAILED')
384+
assert.equal(error.message, refused)
385+
assert.deepEqual(error.details, { errors: { 'mutations.1.path': [refused] } })
386+
assert.deepEqual(cloudSyncPathRefusal(error, inline)?.indices, [1])
387+
388+
// Several requests: a large file's upload refused for its path is named by
389+
// its place in the batch, and carries what the inline request applied.
390+
const mixed = { mutations: [upsert('kept', 'inbox/Note.md'), upsert('big', '.zennotes/agent-edits/big.json', 5 * 1024 * 1024 + 1)] }
391+
const split = await client.mutate('vault-1', mixed).catch((caught: unknown) => caught)
392+
assert.ok(split instanceof CloudSyncPathRefusedError)
393+
assert.deepEqual(split.refusal.indices, [1])
394+
assert.deepEqual(split.refusal.applied.acknowledged.map((entry: { operation_id: string }) => entry.operation_id), ['kept'])
395+
assert.deepEqual(cloudSyncPathRefusal(split, mixed)?.indices, [1])
396+
})

‎src/bridge/cloud-sync-client.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: M
114114
: `ZenNotes Cloud request failed (${response.status}).`),
115115
response.status,
116116
typeof error?.code === 'string' ? error.code : null,
117-
isRecord(error?.details) ? error.details : null,
117+
errorDetails(error?.details, response.data?.errors),
118118
response.headers ?? {}
119119
)
120120
}
@@ -264,6 +264,16 @@ function unansweredUpload(error: unknown): boolean {
264264
return error instanceof MobileDirectUploadError && error.status === 0 && error.code === 'DIRECT_UPLOAD_FAILED'
265265
}
266266

267+
/** The answer's error details, with a 422's field errors kept under
268+
* `details.errors`: which field Cloud refused (`mutations.3.path`) is only in
269+
* those keys, and sync needs it to set one refused path aside instead of
270+
* stopping the whole run (#1004, core 2.80; desktop's asErrorPayload). */
271+
export function errorDetails(details: unknown, errors: unknown): Record<string, unknown> | null {
272+
const kept = isRecord(details) ? details : null
273+
const fields = isRecord(errors) ? errors : null
274+
return kept || fields ? { ...kept, ...(fields ? { errors: fields } : {}) } : null
275+
}
276+
267277
function firstValidationMessage(errors: unknown): string | null {
268278
if (!errors || typeof errors !== 'object') return null
269279

‎src/bridge/mobile-bridge.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ import {
157157
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
158158
import { isPhoneViewport } from '../viewport'
159159

160-
let appVersion = '1.1.50'
160+
let appVersion = '1.1.51'
161161

162162
export async function loadNativeAppVersion(): Promise<string> {
163163
try {
@@ -406,6 +406,10 @@ const MOBILE_CAPABILITIES: ZenCapabilities = {
406406
supportsCloudSync: true,
407407
supportsCliInstall: false,
408408
supportsCustomTemplates: true,
409+
// vault.json's template folder (#1024) survives a save from this device
410+
// (vault-fs.ts normalizes it as desktop does) and a folder rename follows
411+
// it. A self-hosted vault answers with its server's own flag instead.
412+
supportsTemplateFolder: true,
409413
// TextMate grammar import is a desktop feature for now: grammars are
410414
// per-app (not per-vault), so nothing desyncs by gating this off. The
411415
// Settings section hides itself behind this flag.

‎src/bridge/mobile-direct-upload.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type Zne1ChunkCipherParams
1919
} from '@zennotes/shared-domain/cloud-e2ee'
2020
import { CloudE2eeLockedError, CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault'
21+
import { CloudSyncPathRefusedError, cloudSyncPathRefusal } from '@zennotes/shared-domain/cloud-sync-refusal'
2122
import {
2223
CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES,
2324
MobileDirectUploadError,
@@ -197,6 +198,79 @@ describe('mutateWithMobileDirectUploads', () => {
197198
})
198199
})
199200

201+
describe('a path Cloud refuses (#1004)', () => {
202+
const refusedText = 'This path is not eligible for vault sync.'
203+
const validation = (errors: Record<string, string[]>) =>
204+
new MobileDirectUploadError(refusedText, 422, 'VALIDATION_FAILED', { errors })
205+
const small = (operation: string, path: string): CloudSyncUpsertMutation =>
206+
({ ...upsertMutation(4, 'YWJjZA=='), operation_id: operation, item_id: `${operation}-item`, path })
207+
const large = (operation: string, path: string): CloudSyncUpsertMutation => {
208+
const byteLength = CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES + 1
209+
return { ...upsertMutation(byteLength, Buffer.alloc(byteLength, 5).toString('base64')), operation_id: operation, item_id: `${operation}-item`, path }
210+
}
211+
const ack = (mutation: CloudSyncUpsertMutation, sequence: number) =>
212+
({ operation_id: mutation.operation_id, item_id: mutation.item_id, revision: 1, sequence })
213+
214+
it('names an inline mutation by its place in the whole batch, with the direct upload that went before', async () => {
215+
const big = large('big', 'assets/photo.png')
216+
const kept = small('kept', 'inbox/Note.md')
217+
const refused = small('refused', '.zennotes/agent-edits/inbox/Note.md.json')
218+
const body = { mutations: [big, kept, refused] }
219+
const api = fakeApi({
220+
initiateUpload: async () => uploadInstruction(big, 'https://objects.example.test/upload'),
221+
completeUpload: async () => ({
222+
data: { id: 'upload-1', operation_id: big.operation_id, status: 'completed' as const, result: { acknowledged: [ack(big, 4)], conflicts: [], cursor: 4 } }
223+
}),
224+
// The inline request carries kept and refused; Cloud names its own index 1.
225+
mutate: async () => { throw validation({ 'mutations.1.path': [refusedText] }) }
226+
})
227+
228+
const error = await mutateWithMobileDirectUploads(api, 'vault-1', body, async () => {}).catch((caught: unknown) => caught)
229+
230+
assert.ok(error instanceof CloudSyncPathRefusedError)
231+
assert.deepEqual(error.refusal.indices, [2])
232+
assert.deepEqual(error.refusal.applied, { acknowledged: [ack(big, 4)], conflicts: [], cursor: 4 })
233+
assert.deepEqual(cloudSyncPathRefusal(error, body)?.indices, [2])
234+
})
235+
236+
it('names a direct upload refused for its own path by its place in the batch, with what went before', async () => {
237+
const kept = small('kept', 'inbox/Note.md')
238+
const big = large('big', '.zennotes/agent-edits/assets/photo.png.json')
239+
const body = { mutations: [kept, big] }
240+
let uploaded = false
241+
const api = fakeApi({
242+
mutate: async () => ({ acknowledged: [ack(kept, 3)], conflicts: [], cursor: 3 }),
243+
initiateUpload: async () => { throw validation({ path: [refusedText] }) }
244+
})
245+
246+
const error = await mutateWithMobileDirectUploads(api, 'vault-1', body, async () => { uploaded = true })
247+
.catch((caught: unknown) => caught)
248+
249+
assert.ok(error instanceof CloudSyncPathRefusedError)
250+
assert.deepEqual(error.refusal.indices, [1])
251+
assert.deepEqual(error.refusal.applied, { acknowledged: [ack(kept, 3)], conflicts: [], cursor: 3 })
252+
assert.equal(uploaded, false)
253+
})
254+
255+
it('stops as before when Cloud refuses anything but a path', async () => {
256+
const big = large('big', 'assets/photo.png')
257+
const other = small('other', 'inbox/Note.md')
258+
const original = validation({ 'mutations.0.base_revision': ['The base revision is invalid.'] })
259+
const api = fakeApi({
260+
initiateUpload: async () => uploadInstruction(big, 'https://objects.example.test/upload'),
261+
completeUpload: async () => ({
262+
data: { id: 'upload-1', operation_id: big.operation_id, status: 'completed' as const, result: { acknowledged: [ack(big, 4)], conflicts: [], cursor: 4 } }
263+
}),
264+
mutate: async () => { throw original }
265+
})
266+
267+
const error = await mutateWithMobileDirectUploads(api, 'vault-1', { mutations: [big, other] }, async () => {})
268+
.catch((caught: unknown) => caught)
269+
270+
assert.equal(error, original)
271+
})
272+
})
273+
200274
describe('encrypted vaults', () => {
201275
const SIGNED_URL = 'https://objects.example.test/upload?signature=signed'
202276

0 commit comments

Comments
 (0)