diff --git a/docs/sync-and-op-log/vector-clocks.md b/docs/sync-and-op-log/vector-clocks.md index 8d5112986b..32ea655b7b 100644 --- a/docs/sync-and-op-log/vector-clocks.md +++ b/docs/sync-and-op-log/vector-clocks.md @@ -227,6 +227,22 @@ An import is an explicit user action to restore **all clients** to a specific st In `detectConflict()`, operations with `opType` of `SYNC_IMPORT`, `BACKUP_IMPORT`, or `REPAIR` return `{ hasConflict: false }` immediately. These operations replace entire state and don't operate on individual entities. +### Multi-Entity Ops and Server-Side Conflict Detection (issue #8334) + +An op may carry `entityIds: string[]` (batch actions: `deleteTasks`, `moveToArchive`, `__updateMultipleTaskSimple`, round-time-spent, task-repeat-cfg/board/issue-provider batches). `detectConflict()` checks the **incoming** op against **all** of its `entityIds`. The op must also be **looked up** by all of its entities once stored — otherwise a later stale write to a non-first entity would find no prior writer and be wrongly accepted as non-conflicting. + +To make that symmetric, the `operations` row stores: + +- `entity_id` — the client-supplied scalar. For batch ops the client sets it to `entityIds[0]` (`operation-log.effects.ts`), but the **server does not enforce** `entity_id === entityIds[0]`. It is the lookup key for single-entity ops and the first entity of multi-entity ops, and is also used by duplicate detection. +- `entity_ids` — the entity set for **multi-entity ops only** (a `text[]` column; populated via `getStoredEntityIds(op)`, which returns `[]` for single-entity ops). Keeping single-entity rows out of this column keeps the `GIN(entity_ids)` index small and the array-branch lookup cheap. + +The lookups in `conflict.ts` match a requested entity as the scalar `entity_id` **or** a member of `entity_ids`: + +- `detectConflictForEntity` (single) — Prisma `where: { OR: [{ entityId }, { entityIds: { has: entityId } }] }`, ordered by `server_seq`. The `OR` spans the `entity_id` btree and the `entity_ids` GIN, so the planner uses a `BitmapOr` + sort bounded by the entity's stored version depth (op-log pruning keeps that small). If a real-Postgres `EXPLAIN` on a deep-history entity ever shows this hot path is a problem, the escalation is two ordered `LIMIT 1` lookups (scalar btree + `entity_ids` GIN) taking the higher `server_seq` — the array side stays small because the column is multi-entity-only. +- `detectConflictForEntities` / `prefetchLatestEntityOpsForBatch` (batch) — raw SQL unnesting `CASE WHEN cardinality(entity_ids) > 0 THEN entity_ids ELSE ARRAY[entity_id] END`, with a `entity_ids && ... OR entity_id = ANY(...)` prefilter so the `GIN(entity_ids)` index (migration `20260613000001`) and the existing `entity_id` btree stay usable. + +**Forward-only by design:** rows written before migration `20260613000000` have an empty `entity_ids` array and fall back to the scalar `entity_id` (= first entity) in the `CASE` expression / scalar branch above — there is no `UPDATE` backfill. Entities 2..n of already-stored multi-entity ops were never persisted and are unrecoverable, so they remain invisible to conflict detection until that entity gets a fresh write. This residual is bounded: client-side LWW is unaffected (the client persists the full op and `VectorClockService.getEntityFrontier()` fans each op out to **every** entity), and the server only builds an authoritative snapshot from non-encrypted ops (`replayOpsToState()` throws on encrypted ops), so the pre-fix gap could only surface a stale value to a fresh client on non-encrypted self-hosted servers. + ### The `SyncImportFilterService` Algorithm Implemented in `src/app/op-log/sync/sync-import-filter.service.ts`: diff --git a/packages/super-sync-server/prisma/migrations/20260613000000_add_operation_entity_ids/migration.sql b/packages/super-sync-server/prisma/migrations/20260613000000_add_operation_entity_ids/migration.sql new file mode 100644 index 0000000000..d612bde180 --- /dev/null +++ b/packages/super-sync-server/prisma/migrations/20260613000000_add_operation_entity_ids/migration.sql @@ -0,0 +1,15 @@ +-- Add the entity_ids array column backing #8334 multi-entity conflict detection. +-- +-- Ops can touch multiple entities (deleteTasks, moveToArchive, __updateMultipleTaskSimple, +-- round-time-spent, batch board/issue-provider actions). Until now only the scalar +-- entity_id (= entityIds[0]) was persisted, so once a multi-entity op was stored only its +-- first entity participated in future conflict lookups; a later stale write to entity 2..n +-- found no prior writer and was wrongly accepted. entity_ids carries the full set so the +-- conflict lookups (src/sync/conflict.ts) can match any touched entity across uploads. +-- +-- ADD COLUMN ... NOT NULL DEFAULT is a metadata-only operation on PostgreSQL 11+ +-- (the default is stored in the catalog, no table rewrite, no long lock). There is no data +-- backfill on purpose: pre-migration rows keep an empty array and fall back to entity_id in +-- the conflict lookups, so the fix is forward-only (entities 2..n of already-stored ops were +-- never persisted and are unrecoverable). +ALTER TABLE "operations" ADD COLUMN "entity_ids" TEXT[] NOT NULL DEFAULT '{}'; diff --git a/packages/super-sync-server/prisma/migrations/20260613000001_add_operation_entity_ids_gin_index/migration.sql b/packages/super-sync-server/prisma/migrations/20260613000001_add_operation_entity_ids_gin_index/migration.sql new file mode 100644 index 0000000000..46efe94028 --- /dev/null +++ b/packages/super-sync-server/prisma/migrations/20260613000001_add_operation_entity_ids_gin_index/migration.sql @@ -0,0 +1,20 @@ +-- GIN index for #8334 multi-entity conflict detection. +-- +-- The latest-writer lookups match a requested entity id against entity_ids +-- (entity_ids @> ARRAY[id] / entity_ids && ARRAY[...]). A GIN index on the array +-- lets those match without scanning a user's operation history. +-- +-- Single-statement CREATE INDEX CONCURRENTLY: `prisma migrate deploy` applies it +-- natively because PostgreSQL does not wrap a single statement in an implicit +-- transaction (CONCURRENTLY is forbidden inside a transaction). See +-- prisma/migrations/README.md. +-- +-- Avoid name-only idempotency: an interrupted concurrent build leaves an INVALID +-- index with this name, which must fail loudly and be rebuilt rather than be +-- silently skipped (matching the 20260511000000 precedent). Pre-migration rows +-- have an empty entity_ids array; the conflict lookups fall back to the scalar +-- entity_id (served by the existing +-- operations_user_id_entity_type_entity_id_server_seq index), so no data backfill +-- is needed for this index to be correct. +CREATE INDEX CONCURRENTLY "operations_entity_ids_gin" + ON "operations" USING GIN ("entity_ids"); diff --git a/packages/super-sync-server/prisma/schema.prisma b/packages/super-sync-server/prisma/schema.prisma index d2bbc7e361..5e0e33b0ab 100644 --- a/packages/super-sync-server/prisma/schema.prisma +++ b/packages/super-sync-server/prisma/schema.prisma @@ -69,6 +69,14 @@ model Operation { opType String @map("op_type") entityType String @map("entity_type") entityId String? @map("entity_id") + // Entity set for multi-entity (batch) ops only — single-entity ops store [] and + // are matched via the scalar `entityId` (the client sets `entityId` to entityIds[0] + // but the server does not enforce that). Conflict detection consults this array so + // writes to a non-first entity are not invisible across uploads (#8334). + // Pre-migration rows have an empty array and fall back to `entityId` in the + // conflict lookups, so the backfill is forward-only (older entities 2..n are + // unrecoverable — they were never persisted). + entityIds String[] @default([]) @map("entity_ids") payload Json payloadBytes BigInt @default(0) @map("payload_bytes") vectorClock Json @map("vector_clock") @@ -85,6 +93,8 @@ model Operation { @@index([userId, clientId]) @@index([userId, receivedAt]) // Restore-point opType lookups use a raw partial index in the 20260512000000 migration. + // Multi-entity conflict lookups (#8334) use a raw GIN index on entity_ids in the + // 20260613000001 migration (Prisma does not model GIN array indexes here). @@map("operations") } diff --git a/packages/super-sync-server/src/sync/conflict.ts b/packages/super-sync-server/src/sync/conflict.ts index 44ff53a92f..8ad673fdab 100644 --- a/packages/super-sync-server/src/sync/conflict.ts +++ b/packages/super-sync-server/src/sync/conflict.ts @@ -68,16 +68,28 @@ export const detectConflictForEntities = async ( start, start + CONFLICT_DETECTION_ENTITY_BATCH_SIZE, ); + // Match each requested id against the op's full entity set: entity_ids when + // present, else the scalar entity_id (pre-migration rows). The `&&`/`= ANY` + // prefilter keeps the GIN(entity_ids) + entity_id indexes usable; the unnest + // + `eid = ANY` keeps only the requested entities, then DISTINCT ON picks the + // latest op per entity. Kept inline (not a shared fragment) so the positional + // params stay stable for the conflict-detection.spec mock; the same shape is + // duplicated in prefetchLatestEntityOpsForBatch — keep them in sync. (#8334) + const idArray = Prisma.sql`ARRAY[${Prisma.join(batchEntityIds)}]::text[]`; const latestOps = await tx.$queryRaw` - SELECT DISTINCT ON (entity_id) - entity_id AS "entityId", - client_id AS "clientId", - vector_clock AS "vectorClock" - FROM operations - WHERE user_id = ${userId} - AND entity_type = ${op.entityType} - AND entity_id IN (${Prisma.join(batchEntityIds)}) - ORDER BY entity_id, server_seq DESC + SELECT DISTINCT ON (eid) + eid AS "entityId", + o.client_id AS "clientId", + o.vector_clock AS "vectorClock" + FROM operations o + CROSS JOIN LATERAL unnest( + CASE WHEN cardinality(o.entity_ids) > 0 THEN o.entity_ids ELSE ARRAY[o.entity_id] END + ) AS eid + WHERE o.user_id = ${userId} + AND o.entity_type = ${op.entityType} + AND (o.entity_ids && ${idArray} OR o.entity_id = ANY(${idArray})) + AND eid = ANY(${idArray}) + ORDER BY eid, o.server_seq DESC `; const latestOpByEntityId = new Map(); @@ -173,20 +185,28 @@ export const detectConflictForEntity = async ( entityId: string, tx: Prisma.TransactionClient, ): Promise => { - // Get the latest operation for this entity + // Get the latest op that touched this entity. A multi-entity (batch) op stores + // its full set in entity_ids, so match the entity as the scalar entity_id OR a + // member of entity_ids. Single-entity ops store an empty array and are matched + // via the scalar; pre-migration rows likewise fall back to the scalar (#8334). + // + // PERF: the OR spans the entity_id btree + the entity_ids GIN, so the planner + // uses a BitmapOr + sort rather than an ordered LIMIT-1 walk. Bounded by one + // entity's stored version depth (op-log pruning keeps that small, so the sort is + // sub-ms in practice). If a real-Postgres EXPLAIN on a deep-history entity ever + // shows this hot path (run per single-entity upload) is a problem, split into two + // ordered LIMIT-1 lookups (scalar btree + entity_ids GIN) and take the higher + // server_seq — the array branch stays small because entity_ids is multi-entity-only. + // The batch unnest paths (detectConflictForEntities / prefetchLatestEntityOpsForBatch) + // carry the larger sort, so EXPLAIN those first under heavy-user latency. const existingOp = await tx.operation.findFirst({ where: { userId, entityType: op.entityType, - entityId, - }, - select: { - clientId: true, - vectorClock: true, - }, - orderBy: { - serverSeq: 'desc', + OR: [{ entityId }, { entityIds: { has: entityId } }], }, + select: { clientId: true, vectorClock: true }, + orderBy: { serverSeq: 'desc' }, }); // No existing operation = no conflict @@ -287,6 +307,11 @@ export const toStableJsonValue = (value: unknown): unknown => { return value; }; +/** + * All entities an op touches (full set, including single-entity ops). Use for the + * incoming-op conflict check and the in-memory in-batch map — NOT for the persisted + * `entity_ids` column, which uses {@link getStoredEntityIds} (multi-entity only). + */ export const getConflictEntityIds = (op: Operation): string[] => { const rawEntityIds = op.entityIds?.length ? op.entityIds @@ -296,6 +321,26 @@ export const getConflictEntityIds = (op: Operation): string[] => { return Array.from(new Set(rawEntityIds)); }; +/** + * The entity_ids array to persist with an op. Ops whose touched-entity set is + * already covered by the scalar entity_id store an empty array, so single-entity + * ops (the vast majority) stay out of the entity_ids GIN index — keeping it small + * and off their insert write path (Postgres GIN indexes no keys for an empty + * array). Any other set is stored in full. + * + * The gate is "is the set exactly [entity_id]?", NOT "length > 1": a batch op + * whose ids dedup to a single value that differs from entity_id (the server does + * not enforce entity_id === entityIds[0]) must still be stored, or that entity + * would be invisible to conflict lookups (#8334). + */ +export const getStoredEntityIds = (op: Operation): string[] => { + const ids = getConflictEntityIds(op); + if (ids.length <= 1 && ids[0] === op.entityId) { + return []; + } + return ids; +}; + export const getEntityConflictKey = (entityType: string, entityId: string): string => { return `${entityType}\u0000${entityId}`; }; @@ -332,22 +377,36 @@ export const prefetchLatestEntityOpsForBatch = async ( start < entityPairs.length; start += CONFLICT_DETECTION_ENTITY_BATCH_SIZE ) { - const touchedRows = entityPairs - .slice(start, start + CONFLICT_DETECTION_ENTITY_BATCH_SIZE) - .map(({ entityType, entityId }) => Prisma.sql`(${entityType}, ${entityId})`); + const batchPairs = entityPairs.slice( + start, + start + CONFLICT_DETECTION_ENTITY_BATCH_SIZE, + ); + const touchedRows = batchPairs.map( + ({ entityType, entityId }) => Prisma.sql`(${entityType}, ${entityId})`, + ); + // Prefilter array (all requested ids) so the JOIN below can match a requested + // id inside a stored op's entity_ids set, not just its scalar entity_id, while + // keeping the GIN(entity_ids) + entity_id indexes usable. (#8334) + const idArray = Prisma.sql`ARRAY[${Prisma.join( + batchPairs.map((pair) => pair.entityId), + )}]::text[]`; const latestOps = await tx.$queryRaw` - SELECT DISTINCT ON (o.entity_type, o.entity_id) + SELECT DISTINCT ON (o.entity_type, eid) o.entity_type AS "entityType", - o.entity_id AS "entityId", + eid AS "entityId", o.client_id AS "clientId", o.vector_clock AS "vectorClock" FROM operations o + CROSS JOIN LATERAL unnest( + CASE WHEN cardinality(o.entity_ids) > 0 THEN o.entity_ids ELSE ARRAY[o.entity_id] END + ) AS eid JOIN (VALUES ${Prisma.join(touchedRows)}) AS touched(entity_type, entity_id) ON touched.entity_type = o.entity_type - AND touched.entity_id = o.entity_id + AND touched.entity_id = eid WHERE o.user_id = ${userId} - ORDER BY o.entity_type, o.entity_id, o.server_seq DESC + AND (o.entity_ids && ${idArray} OR o.entity_id = ANY(${idArray})) + ORDER BY o.entity_type, eid, o.server_seq DESC `; for (const latestOp of latestOps) { diff --git a/packages/super-sync-server/src/sync/services/operation-upload.service.ts b/packages/super-sync-server/src/sync/services/operation-upload.service.ts index 48d0620832..00c69cc7b4 100644 --- a/packages/super-sync-server/src/sync/services/operation-upload.service.ts +++ b/packages/super-sync-server/src/sync/services/operation-upload.service.ts @@ -21,6 +21,7 @@ import { detectConflict, getBatchConflictEntityPairs, getConflictEntityIds, + getStoredEntityIds, getEntityConflictKey, isSameDuplicateOperation, prefetchLatestEntityOpsForBatch, @@ -544,6 +545,10 @@ export class OperationUploadService { opType: candidate.op.opType, entityType: candidate.op.entityType, entityId: candidate.op.entityId ?? null, + // Persist the full entity set for multi-entity ops so conflict detection + // can match a write to any touched entity across uploads, not just + // entityIds[0]; single-entity ops store [] and use the scalar (#8334). + entityIds: getStoredEntityIds(candidate.op), payload: candidate.op.payload as Prisma.InputJsonValue, payloadBytes: BigInt(candidate.storageBytes), vectorClock: candidate.op.vectorClock as Prisma.InputJsonValue, @@ -800,6 +805,10 @@ export class OperationUploadService { opType: op.opType, entityType: op.entityType, entityId: op.entityId ?? null, + // Persist the full entity set for multi-entity ops so conflict detection + // can match a write to any touched entity across uploads, not just + // entityIds[0]; single-entity ops store [] and use the scalar (#8334). + entityIds: getStoredEntityIds(op), payload: op.payload as Prisma.InputJsonValue, payloadBytes: BigInt(sized.bytes), vectorClock: op.vectorClock as Prisma.InputJsonValue, diff --git a/packages/super-sync-server/src/sync/services/validation.service.ts b/packages/super-sync-server/src/sync/services/validation.service.ts index 87102c6dc0..382467ee12 100644 --- a/packages/super-sync-server/src/sync/services/validation.service.ts +++ b/packages/super-sync-server/src/sync/services/validation.service.ts @@ -13,7 +13,7 @@ import { sanitizeVectorClock, validatePayload, } from '../sync.types'; -import { ENTITY_TYPES } from '@sp/shared-schema'; +import { ENTITY_TYPES, SUPER_SYNC_MAX_ENTITY_IDS_PER_OP } from '@sp/shared-schema'; import { Logger } from '../../logger'; /** @@ -106,6 +106,32 @@ export class ValidationService { } } + // Validate the multi-entity set the same way as the scalar entityId. The HTTP + // contract Zod schema also bounds this, but enforcing it here keeps the invariant + // with the op (defense-in-depth for any non-HTTP caller) since entityIds is now + // persisted and consulted by conflict detection (#8334). + if (op.entityIds !== undefined && op.entityIds !== null) { + if ( + !Array.isArray(op.entityIds) || + op.entityIds.length > SUPER_SYNC_MAX_ENTITY_IDS_PER_OP + ) { + return { + valid: false, + error: 'Invalid entityIds: not an array or too many entries', + errorCode: SYNC_ERROR_CODES.INVALID_ENTITY_ID, + }; + } + for (const id of op.entityIds) { + if (typeof id !== 'string' || id.length > 255 || id.trim().length === 0) { + return { + valid: false, + error: 'Invalid entityIds element: must be a non-empty string <= 255 chars', + errorCode: SYNC_ERROR_CODES.INVALID_ENTITY_ID, + }; + } + } + } + // Require entityId for regular entity operations. // Full-state operations (SYNC_IMPORT, BACKUP_IMPORT, REPAIR) and bulk entity types // (ALL, RECOVERY) legitimately don't have entityId. diff --git a/packages/super-sync-server/tests/conflict-detection.spec.ts b/packages/super-sync-server/tests/conflict-detection.spec.ts index 46d4645d40..475efcef40 100644 --- a/packages/super-sync-server/tests/conflict-detection.spec.ts +++ b/packages/super-sync-server/tests/conflict-detection.spec.ts @@ -63,6 +63,28 @@ vi.mock('../src/db', async () => { applyOperationSelect(state.operations.get(args.where.id), args.select) || null ); } + // Single-entity conflict lookup: where { userId, entityType, + // OR: [{ entityId: X }, { entityIds: { has: X } }] } — match X as the + // scalar entity_id OR a member of the entity_ids array (#8334). + if (Array.isArray(args.where?.OR) && args.where?.entityType) { + state.entityConflictFindFirstCount++; + const scalarClause = args.where.OR.find((c: any) => 'entityId' in c); + const hasClause = args.where.OR.find( + (c: any) => c.entityIds?.has !== undefined, + ); + const targetId = scalarClause?.entityId ?? hasClause?.entityIds?.has; + const ops = Array.from(state.operations.values()) + .filter( + (op: any) => + op.userId === args.where.userId && + op.entityType === args.where.entityType && + (op.entityId === targetId || + (Array.isArray(op.entityIds) && op.entityIds.includes(targetId))), + ) + .sort((a: any, b: any) => b.serverSeq - a.serverSeq); + return applyOperationSelect(ops[0], args.select) || null; + } + // Scalar-only lookup (other callers): where { userId, entityType, entityId }. if (args.where?.entityId && args.where?.entityType) { state.entityConflictFindFirstCount++; const ops = Array.from(state.operations.values()) @@ -200,23 +222,29 @@ vi.mock('../src/db', async () => { (entityId): entityId is string => typeof entityId === 'string', ), ); + // An op covers an entity via its full entity_ids set (multi-entity ops), + // falling back to the scalar entity_id for pre-migration rows (#8334). + const coveredEntityIds = (op: any): string[] => + Array.isArray(op.entityIds) && op.entityIds.length > 0 + ? op.entityIds + : op.entityId != null + ? [op.entityId] + : []; const latestByEntityId = new Map(); const ops = Array.from(state.operations.values()) - .filter( - (op: any) => - op.userId === userId && - op.entityType === entityType && - batchEntityIds.has(op.entityId), - ) + .filter((op: any) => op.userId === userId && op.entityType === entityType) .sort((a: any, b: any) => b.serverSeq - a.serverSeq); for (const op of ops) { - if (!op.entityId || latestByEntityId.has(op.entityId)) continue; - latestByEntityId.set(op.entityId, op); + for (const eid of coveredEntityIds(op)) { + if (batchEntityIds.has(eid) && !latestByEntityId.has(eid)) { + latestByEntityId.set(eid, op); + } + } } - return Array.from(latestByEntityId.values()).map((op: any) => ({ - entityId: op.entityId, + return Array.from(latestByEntityId.entries()).map(([eid, op]) => ({ + entityId: eid, clientId: op.clientId, vectorClock: op.vectorClock, })); @@ -969,5 +997,75 @@ describe('Conflict Detection', () => { }); expect(result[0].error).toContain('TASK:task-2'); }); + + // Regression for #8334. The test above proves an *incoming* multi-entity op + // is checked against all its ids. These two prove the reverse — a *stored* + // multi-entity op exposes every entity to later conflict lookups, not just + // entityIds[0] — across both the single-entity and batch lookup paths. + it('#8334 single path: stale write to a non-first stored entity conflicts', async () => { + const service = getSyncService(); + + // clientA stores a multi-entity op over [task-1, task-2] + // (persisted scalar entity_id = task-1, entity_ids = both). + const stored = await service.uploadOps(userId, clientA, [ + createOp({ + entityId: 'task-1', + entityIds: ['task-1', 'task-2'], + clientId: clientA, + vectorClock: { [clientA]: 1 }, + }), + ]); + expect(stored[0].accepted).toBe(true); + + // clientB sends a stale single-entity op for task-2 (the SECOND entity). + // {clientB:1} is CONCURRENT with {clientA:1}. + const result = await service.uploadOps(userId, clientB, [ + createOp({ + entityId: 'task-2', + clientId: clientB, + vectorClock: { [clientB]: 1 }, + }), + ]); + + expect(result[0]).toMatchObject({ + accepted: false, + errorCode: SYNC_ERROR_CODES.CONFLICT_CONCURRENT, + }); + }); + + // NOTE: with the default (non-batch) upload config this exercises + // `detectConflictForEntities`. The `prefetchLatestEntityOpsForBatch` variant + // (batchUpload=true) shares the same entity_ids matching SQL but is not driven + // here; its raw query is validated separately against real Postgres. + it('#8334 batch path: incoming multi-entity op hits a non-first stored entity', async () => { + const service = getSyncService(); + + const stored = await service.uploadOps(userId, clientA, [ + createOp({ + entityId: 'task-1', + entityIds: ['task-1', 'task-2'], + clientId: clientA, + vectorClock: { [clientA]: 1 }, + }), + ]); + expect(stored[0].accepted).toBe(true); + + // Incoming *multi-entity* op (→ batch lookup path) touching task-2. + const result = await service.uploadOps(userId, clientB, [ + createOp({ + entityId: 'task-2', + entityIds: ['task-2', 'task-9'], + clientId: clientB, + vectorClock: { [clientB]: 1 }, + }), + ]); + + expect(testState.batchConflictQueryCount).toBeGreaterThan(0); + expect(result[0]).toMatchObject({ + accepted: false, + errorCode: SYNC_ERROR_CODES.CONFLICT_CONCURRENT, + }); + expect(result[0].error).toContain('TASK:task-2'); + }); }); }); diff --git a/packages/super-sync-server/tests/issue-8334-detect-conflict.spec.ts b/packages/super-sync-server/tests/issue-8334-detect-conflict.spec.ts new file mode 100644 index 0000000000..81587a5c88 --- /dev/null +++ b/packages/super-sync-server/tests/issue-8334-detect-conflict.spec.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { detectConflict, getStoredEntityIds } from '../src/sync/conflict'; +import type { Operation } from '../src/sync/sync.types'; + +/** + * Unit-level regression for #8334's single-entity lookup path + * (detectConflict → detectConflictForEntity). It exercises the REAL function + * against a tx mock modelling how production persists ops: multi-entity ops carry + * the full entity_ids array, single-entity ops store []. detectConflictForEntity + * matches an entity via a Prisma `OR: [{entityId}, {entityIds:{has}}]` filter, + * which the mock's findFirst reproduces. + * + * The batch lookup paths (raw unnest SQL) are validated separately against real + * Postgres semantics and in conflict-detection.spec.ts. + */ +type StoredRow = { + userId: number; + entityType: string; + entityId: string | null; + entityIds: string[]; + clientId: string; + serverSeq: number; + vectorClock: Record; +}; + +const makeTx = (rows: StoredRow[]): any => ({ + operation: { + // Mirrors: where { userId, entityType, OR: [{entityId:X}, {entityIds:{has:X}}] } + findFirst: async ({ where }: any) => { + const target = + where.OR.find((c: any) => 'entityId' in c)?.entityId ?? + where.OR.find((c: any) => c.entityIds?.has !== undefined)?.entityIds?.has; + return ( + rows + .filter( + (r) => + r.userId === where.userId && + r.entityType === where.entityType && + (r.entityId === target || r.entityIds.includes(target)), + ) + .sort((a, b) => b.serverSeq - a.serverSeq)[0] ?? null + ); + }, + }, +}); + +const staleOp = (entityId: string): Operation => + ({ + id: 'op-b', + clientId: 'B', + actionType: 'UPDATE_TASK', + opType: 'UPD', + entityType: 'TASK', + entityId, + vectorClock: { B: 1 }, + timestamp: 1, + schemaVersion: 1, + }) as unknown as Operation; + +const multiEntityRow: StoredRow = { + userId: 1, + entityType: 'TASK', + entityId: 'task-1', + entityIds: ['task-1', 'task-2'], + clientId: 'A', + serverSeq: 1, + vectorClock: { A: 1 }, +}; + +describe('#8334 detectConflict single-entity path', () => { + it('rejects a stale write to a NON-FIRST entity of a stored multi-entity op', async () => { + const result = await detectConflict(1, staleOp('task-2'), makeTx([multiEntityRow])); + expect(result.hasConflict).toBe(true); + }); + + it('still rejects a stale write to the first/scalar entity', async () => { + const result = await detectConflict(1, staleOp('task-1'), makeTx([multiEntityRow])); + expect(result.hasConflict).toBe(true); + }); + + it('finds a pre-migration single-entity row via the scalar fallback', async () => { + const oldRow: StoredRow = { + userId: 1, + entityType: 'TASK', + entityId: 'task-3', + entityIds: [], // pre-migration: empty array, only scalar persisted + clientId: 'A', + serverSeq: 1, + vectorClock: { A: 1 }, + }; + const result = await detectConflict(1, staleOp('task-3'), makeTx([oldRow])); + expect(result.hasConflict).toBe(true); + }); + + it('does not flag an unrelated entity', async () => { + const result = await detectConflict(1, staleOp('task-9'), makeTx([multiEntityRow])); + expect(result.hasConflict).toBe(false); + }); +}); + +describe('#8334 getStoredEntityIds', () => { + const op = (over: Partial): Operation => over as unknown as Operation; + + it('stores [] for a single-entity op (covered by the scalar)', () => { + expect(getStoredEntityIds(op({ entityId: 'task-1' }))).toEqual([]); + expect(getStoredEntityIds(op({ entityId: 'task-1', entityIds: ['task-1'] }))).toEqual( + [], + ); + }); + + it('stores the full set for a multi-entity op', () => { + expect( + getStoredEntityIds(op({ entityId: 'task-1', entityIds: ['task-1', 'task-2'] })), + ).toEqual(['task-1', 'task-2']); + }); + + it('stores [] for an entity-less op', () => { + expect(getStoredEntityIds(op({}))).toEqual([]); + }); + + it('stores the id when a batch op dedups to one value that differs from entityId', () => { + // The server does not enforce entityId === entityIds[0]; without this the + // entity would be invisible to conflict lookups (#8334). + expect( + getStoredEntityIds(op({ entityId: 'task-Z', entityIds: ['task-A', 'task-A'] })), + ).toEqual(['task-A']); + }); +}); diff --git a/packages/super-sync-server/tests/migration-sql.spec.ts b/packages/super-sync-server/tests/migration-sql.spec.ts index b48779036b..64e6645c17 100644 --- a/packages/super-sync-server/tests/migration-sql.spec.ts +++ b/packages/super-sync-server/tests/migration-sql.spec.ts @@ -133,6 +133,38 @@ describe('performance migrations', () => { expect(migrationSql).not.toMatch(/\bBEGIN\b|\bCOMMIT\b/i); }); + it('adds the operation entity_ids column as a metadata-only column (no table rewrite)', () => { + const migrationSql = readMigration('20260613000000_add_operation_entity_ids'); + + // Same fast-path guards as payload_bytes: ADD COLUMN with a constant default is + // metadata-only on PG 11+. A future edit to an expression default or a separate + // UPDATE backfill would rewrite/lock a 100M-row table — #8334 is forward-only by + // design (pre-migration rows fall back to the scalar entity_id), so no backfill. + expect(migrationSql).toMatch( + /ALTER TABLE "operations"\s+ADD COLUMN "entity_ids" TEXT\[\] NOT NULL DEFAULT '\{\}'/i, + ); + expect(migrationSql).not.toMatch(/\bUPDATE\b/i); + expect(migrationSql).not.toMatch(/\bUSING\b/i); + expect(migrationSql).not.toMatch(/\bDROP\s+TABLE\b/i); + expect(migrationSql).not.toMatch(/\bBEGIN\b|\bCOMMIT\b/i); + }); + + it('adds the entity_ids GIN index concurrently as a single native-apply statement', () => { + const migrationSql = readMigration( + '20260613000001_add_operation_entity_ids_gin_index', + ); + + expect(migrationSql).toContain('CREATE INDEX CONCURRENTLY'); + expect(migrationSql).toContain('"operations_entity_ids_gin"'); + expect(migrationSql).toContain('USING GIN ("entity_ids")'); + // Bare CREATE (no IF NOT EXISTS / no drop-then-create): an interrupted concurrent + // build must fail loudly, matching the 20260511000000 precedent. + expect(migrationSql).not.toMatch(/\bIF\s+NOT\s+EXISTS\b/i); + expect(migrationSql).not.toMatch(/\bALTER\s+TABLE\b/i); + expect(migrationSql).not.toMatch(/\bDROP\s+TABLE\b/i); + expect(migrationSql).not.toMatch(/\bBEGIN\b|\bCOMMIT\b/i); + }); + it('runs migrations before replacing the app during compose deploys', () => { const deployScript = readFileSync(join(currentDir, '../scripts/deploy.sh'), 'utf8'); const runtimeMigrateScript = readFileSync( diff --git a/packages/super-sync-server/tests/sync.service.spec.ts b/packages/super-sync-server/tests/sync.service.spec.ts index 326afb0a2f..9c7448f827 100644 --- a/packages/super-sync-server/tests/sync.service.spec.ts +++ b/packages/super-sync-server/tests/sync.service.spec.ts @@ -325,17 +325,19 @@ vi.mock('../src/db', async () => { return [{ lastSeq }]; } if (sql.includes('JOIN (VALUES')) { - const txUserId = params[params.length - 1] as number; - const touchedParams = params.slice(0, -1).flatMap((param: unknown): unknown[] => { - if ( - param && - typeof param === 'object' && - Array.isArray((param as { values?: unknown[] }).values) - ) { - return (param as { values: unknown[] }).values; - } - return [param]; - }); + // Params are [touchedRows (VALUES join), userId, idArray, idArray]: userId + // is the only number; the VALUES join is the first Sql fragment, whose + // .values hold the flattened (entity_type, entity_id) touched pairs. The + // idArray prefilter params (#8334) are not needed by the mock. (Previously + // userId was the last param and the join the only fragment.) + const txUserId = params.find((p: unknown) => typeof p === 'number') as number; + const valuesParam = params.find( + (p: unknown) => + !!p && + typeof p === 'object' && + Array.isArray((p as { values?: unknown[] }).values), + ) as { values: unknown[] } | undefined; + const touchedParams = valuesParam?.values ?? []; const touchedPairs = new Set(); for (let i = 0; i < touchedParams.length; i += 2) { touchedPairs.add(`${touchedParams[i]}\u0000${touchedParams[i + 1]}`); diff --git a/packages/super-sync-server/tests/time-tracking-operations.spec.ts b/packages/super-sync-server/tests/time-tracking-operations.spec.ts index 3476d167e3..fea681c0e5 100644 --- a/packages/super-sync-server/tests/time-tracking-operations.spec.ts +++ b/packages/super-sync-server/tests/time-tracking-operations.spec.ts @@ -81,6 +81,24 @@ vi.mock('../src/db', async () => { applyOperationSelect(state.operations.get(args.where.id), args.select) || null ); } + // Single-entity conflict lookup: where { userId, entityType, + // OR: [{ entityId: X }, { entityIds: { has: X } }] } (#8334). + if (Array.isArray(args.where?.OR) && args.where?.entityType) { + const targetId = + args.where.OR.find((c: any) => 'entityId' in c)?.entityId ?? + args.where.OR.find((c: any) => c.entityIds?.has !== undefined)?.entityIds + ?.has; + const ops = Array.from(state.operations.values()) + .filter( + (op: any) => + op.userId === args.where.userId && + op.entityType === args.where.entityType && + (op.entityId === targetId || + (Array.isArray(op.entityIds) && op.entityIds.includes(targetId))), + ) + .sort((a: any, b: any) => b.serverSeq - a.serverSeq); + return applyOperationSelect(ops[0], args.select) || null; + } if (args.where?.entityId && args.where?.entityType) { const ops = Array.from(state.operations.values()) .filter( diff --git a/packages/super-sync-server/tests/validation.service.spec.ts b/packages/super-sync-server/tests/validation.service.spec.ts index 78efc3f676..b2e65c4c31 100644 --- a/packages/super-sync-server/tests/validation.service.spec.ts +++ b/packages/super-sync-server/tests/validation.service.spec.ts @@ -114,6 +114,37 @@ describe('ValidationService', () => { expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID); }); + // === entityIds (multi-entity batch ops) validation (#8334) === + + it('should accept a valid entityIds array', () => { + const op = createValidOp({ entityIds: ['task-1', 'task-2'] }); + expect(validationService.validateOp(op, clientId).valid).toBe(true); + }); + + it('should reject an entityIds element longer than 255 characters', () => { + const op = createValidOp({ entityIds: ['ok', 'x'.repeat(256)] }); + const result = validationService.validateOp(op, clientId); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID); + }); + + it('should reject a non-string / empty entityIds element', () => { + expect( + validationService.validateOp(createValidOp({ entityIds: [123] }), clientId).valid, + ).toBe(false); + expect( + validationService.validateOp(createValidOp({ entityIds: [' '] }), clientId) + .valid, + ).toBe(false); + }); + + it('should reject more than SUPER_SYNC_MAX_ENTITY_IDS_PER_OP entries', () => { + const op = createValidOp({ entityIds: new Array(1001).fill('id') }); + const result = validationService.validateOp(op, clientId); + expect(result.valid).toBe(false); + expect(result.errorCode).toBe(SYNC_ERROR_CODES.INVALID_ENTITY_ID); + }); + // === entityId validation for regular entity types === it('should reject null entityId for regular entity type (TASK)', () => { diff --git a/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.html b/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.html index 3ab1b23f85..0230fc9b55 100644 --- a/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.html +++ b/src/app/features/simple-counter/dialog-simple-counter-edit/dialog-simple-counter-edit.component.html @@ -73,8 +73,10 @@ {{ T.F.SIMPLE_COUNTER.D_EDIT.L_COUNTER | translate }} >('countInput'); + // State readonly localCounter = signal({ ...this.dialogData.simpleCounter, @@ -188,11 +192,21 @@ export class DialogSimpleCounterEditComponent { updateValue(value: number): void { const date = this.selectedDateStr(); + // counts and durations can never be negative; clamp invalid/empty input to 0 + const safeValue = Math.max(0, value || 0); + // A type="number" field still accepts typed/pasted negatives, and the + // one-way [ngModel] binding won't repaint the field when the clamped value + // equals the previously bound value (e.g. editing from 0) — so reset the + // element directly to reject a negative visibly. + const inputEl = this.countInput()?.nativeElement; + if (inputEl && value < 0) { + inputEl.value = String(safeValue); + } this.localCounter.update((counter) => ({ ...counter, countOnDay: { ...counter.countOnDay, - [date]: value, + [date]: safeValue, }, })); } diff --git a/src/app/features/simple-counter/store/simple-counter.reducer.spec.ts b/src/app/features/simple-counter/store/simple-counter.reducer.spec.ts index ae09723feb..a8f6f4663e 100644 --- a/src/app/features/simple-counter/store/simple-counter.reducer.spec.ts +++ b/src/app/features/simple-counter/store/simple-counter.reducer.spec.ts @@ -264,6 +264,45 @@ describe('SimpleCounterReducer', () => { expect(result.entities['counter1']!.countOnDay['2024-01-14']).toBe(5); expect(result.entities['counter1']!.countOnDay['2024-01-15']).toBe(10); }); + + it('should clamp negative values to 0', () => { + const counter = createCounter('counter1', { countOnDay: {} }); + const existingState = createStateWithCounters([counter]); + + const action = SimpleCounterActions.setSimpleCounterCounterToday({ + id: 'counter1', + newVal: -5, + today: '2024-01-15', + }); + const result = simpleCounterReducer(existingState, action); + + expect(result.entities['counter1']!.countOnDay['2024-01-15']).toBe(0); + }); + + it('should leave 0 and positive values unchanged', () => { + const counter = createCounter('counter1', { countOnDay: {} }); + const existingState = createStateWithCounters([counter]); + + const zeroResult = simpleCounterReducer( + existingState, + SimpleCounterActions.setSimpleCounterCounterToday({ + id: 'counter1', + newVal: 0, + today: '2024-01-15', + }), + ); + expect(zeroResult.entities['counter1']!.countOnDay['2024-01-15']).toBe(0); + + const posResult = simpleCounterReducer( + existingState, + SimpleCounterActions.setSimpleCounterCounterToday({ + id: 'counter1', + newVal: 42, + today: '2024-01-15', + }), + ); + expect(posResult.entities['counter1']!.countOnDay['2024-01-15']).toBe(42); + }); }); describe('setSimpleCounterCounterForDate', () => { @@ -280,6 +319,20 @@ describe('SimpleCounterReducer', () => { expect(result.entities['counter1']!.countOnDay['2024-01-10']).toBe(7); }); + + it('should clamp negative values to 0', () => { + const counter = createCounter('counter1', { countOnDay: {} }); + const existingState = createStateWithCounters([counter]); + + const action = SimpleCounterActions.setSimpleCounterCounterForDate({ + id: 'counter1', + date: '2024-01-10', + newVal: -7, + }); + const result = simpleCounterReducer(existingState, action); + + expect(result.entities['counter1']!.countOnDay['2024-01-10']).toBe(0); + }); }); }); diff --git a/src/app/features/simple-counter/store/simple-counter.reducer.ts b/src/app/features/simple-counter/store/simple-counter.reducer.ts index cbef1e7292..213649230c 100644 --- a/src/app/features/simple-counter/store/simple-counter.reducer.ts +++ b/src/app/features/simple-counter/store/simple-counter.reducer.ts @@ -154,7 +154,8 @@ const _reducer = createReducer( changes: { countOnDay: { ...entity.countOnDay, - [today]: newVal, + // counts and durations can never be negative + [today]: Math.max(0, newVal), }, }, }, @@ -173,7 +174,8 @@ const _reducer = createReducer( changes: { countOnDay: { ...entity.countOnDay, - [date]: newVal, + // counts and durations can never be negative + [date]: Math.max(0, newVal), }, }, }, diff --git a/src/app/imex/sync/sync-wrapper.service.ts b/src/app/imex/sync/sync-wrapper.service.ts index d9fbc853aa..70c3dd465e 100644 --- a/src/app/imex/sync/sync-wrapper.service.ts +++ b/src/app/imex/sync/sync-wrapper.service.ts @@ -75,6 +75,7 @@ import { IS_ELECTRON } from '../../app.constants'; import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service'; import { OperationLogSyncService } from '../../op-log/sync/operation-log-sync.service'; import { SyncSessionValidationService } from '../../op-log/sync/sync-session-validation.service'; +import { SyncCycleGuardService } from '../../op-log/sync/sync-cycle-guard.service'; import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service'; import { isSuperSyncWebSocketAccess } from '@sp/sync-providers/super-sync'; import { isTransientNetworkError } from '@sp/sync-providers/http'; @@ -116,6 +117,7 @@ export class SyncWrapperService { private _opLogStore = inject(OperationLogStoreService); private _opLogSyncService = inject(OperationLogSyncService); private _sessionValidation = inject(SyncSessionValidationService); + private _syncCycleGuard = inject(SyncCycleGuardService); private _wrappedProvider = inject(WrappedProviderService); private _hydrationState = inject(HydrationStateService); @@ -280,6 +282,19 @@ export class SyncWrapperService { return 'HANDLED_ERROR'; } this._isSyncInProgress$.next(true); + + // #8309: claim the in-tab sync cycle so the conflict-gate decision, + // setLastServerSeq, and the session-validation latch are serialized against + // the side channels (immediate upload / WS download). Both this claim and + // the re-entry check above are synchronous (no await between), so the + // check-and-set stays atomic. If a (short-lived) side channel is mid-cycle + // we skip rather than block — the next trigger retries. Released in the + // finally below. + if (!this._syncCycleGuard.tryBegin()) { + this._isSyncInProgress$.next(false); + SyncLog.log('Sync skipped: another sync cycle is in progress'); + return 'HANDLED_ERROR'; + } // Open before any async work — see `HydrationStateService.isInSyncWindow`. // Pass 0 to disable the failsafe: the `finally` block below is the // authoritative close, and a slow sync (provider I/O > 2s) would @@ -290,6 +305,7 @@ export class SyncWrapperService { const result = await this._sync(isUserTriggered).finally(() => { this._isSyncInProgress$.next(false); this._hydrationState.closeSyncWindow(); + this._syncCycleGuard.end(); // Safeguard: if _sync() threw or completed without setting a final status, // reset from SYNCING to UNKNOWN_OR_CHANGED to avoid getting stuck in SYNCING state if (this._providerManager.isSyncInProgress) { @@ -976,42 +992,60 @@ export class SyncWrapperService { private async _forceDownload(): Promise { SyncLog.log('SyncWrapperService: forceDownload called - downloading remote state'); + await this.runWithSyncBlocked(async () => { + // #8309: claim the sync cycle so this flow's conflict-gate / session + // latch are isolated from the immediate-upload / WS-download side + // channels. runWithSyncBlocked has already drained any main sync and set + // isEncryptionOperationInProgress, so the only thing that could hold the + // guard here is a short-lived side channel; skip-and-let-the-user-retry + // rather than block. + if (!this._syncCycleGuard.tryBegin()) { + SyncLog.log('Force download skipped: another sync cycle is in progress'); + return; + } + try { + await this._forceDownloadInner(); + } finally { + this._syncCycleGuard.end(); + } + }); + } + + private async _forceDownloadInner(): Promise { // Open a session-validation scope — read after forceDownloadRemoteState // returns so a corrupt downloaded state is reported as ERROR. (#7330) - await this.runWithSyncBlocked(() => - this._sessionValidation.withSession(async () => { - try { - const rawProvider = this._providerManager.getActiveProvider(); - const syncCapableProvider = - await this._wrappedProvider.getOperationSyncCapable(rawProvider); + await this._sessionValidation.withSession(async () => { + try { + const rawProvider = this._providerManager.getActiveProvider(); + const syncCapableProvider = + await this._wrappedProvider.getOperationSyncCapable(rawProvider); - if (!syncCapableProvider) { - SyncLog.warn( - 'SyncWrapperService: Cannot force download - provider not available', - ); - return; - } - - await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider); - if (this._sessionValidation.hasFailed()) { - SyncLog.err( - 'SyncWrapperService: Force download applied but post-sync validation failed; reporting ERROR', - ); - this._providerManager.setSyncStatus('ERROR'); - } else { - this._providerManager.setSyncStatus('IN_SYNC'); - } - SyncLog.log('SyncWrapperService: Force download complete'); - } catch (error) { - SyncLog.err('SyncWrapperService: Force download failed:', error); - const errStr = getSyncErrorStr(error); - this._snackService.open({ - msg: errStr, - type: 'ERROR', - }); + if (!syncCapableProvider) { + SyncLog.warn( + 'SyncWrapperService: Cannot force download - provider not available', + ); + return; } - }), - ); + + await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider); + if (this._sessionValidation.hasFailed()) { + SyncLog.err( + 'SyncWrapperService: Force download applied but post-sync validation failed; reporting ERROR', + ); + this._providerManager.setSyncStatus('ERROR'); + } else { + this._providerManager.setSyncStatus('IN_SYNC'); + } + SyncLog.log('SyncWrapperService: Force download complete'); + } catch (error) { + SyncLog.err('SyncWrapperService: Force download failed:', error); + const errStr = getSyncErrorStr(error); + this._snackService.open({ + msg: errStr, + type: 'ERROR', + }); + } + }); } async configuredAuthForSyncProviderIfNecessary( diff --git a/src/app/op-log/capture/operation-log.effects.spec.ts b/src/app/op-log/capture/operation-log.effects.spec.ts index fe9eb7f536..59a88e40c9 100644 --- a/src/app/op-log/capture/operation-log.effects.spec.ts +++ b/src/app/op-log/capture/operation-log.effects.spec.ts @@ -506,6 +506,37 @@ describe('OperationLogEffects', () => { expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledTimes(2); })); + it('re-extracts the SAME action on the quota-exceeded retry, never a second op (#8307)', fakeAsync(() => { + // Regression (#8307, now structural): the positional dequeue is gone. + // entityChanges is recomputed by the pure, idempotent extractEntityChanges() + // on every write, so the quota retry re-extracts THIS action's changes and + // can never steal the next pending action's entry the way the old + // double-dequeue did. + const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError'); + let appendCount = 0; + mockOpLogStore.appendWithVectorClockUpdate.and.callFake(() => { + appendCount++; + if (appendCount === 1) { + return Promise.reject(quotaError); + } + return Promise.resolve(1); + }); + + const action = createPersistentAction(ActionType.TASK_SHARED_UPDATE); + actions$ = of(action); + + effects.persistOperation$.subscribe(); + + tick(100); + // Append ran twice (initial + retry). Extraction ran once per write and + // always against the same action — no positional queue to mis-consume. + expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledTimes(2); + expect(mockOperationCaptureService.extractEntityChanges).toHaveBeenCalledTimes(2); + expect(mockOperationCaptureService.extractEntityChanges).toHaveBeenCalledWith( + action, + ); + })); + it('should use updated clientId after backup import generates new one', (done) => { const action1 = createPersistentAction(ActionType.TASK_SHARED_ADD); const action2 = createPersistentAction(ActionType.TASK_SHARED_UPDATE); diff --git a/src/app/op-log/capture/operation-log.effects.ts b/src/app/op-log/capture/operation-log.effects.ts index b7cf4e6c5e..e3e8fe06dd 100644 --- a/src/app/op-log/capture/operation-log.effects.ts +++ b/src/app/op-log/capture/operation-log.effects.ts @@ -533,7 +533,11 @@ export class OperationLogEffects implements DeferredLocalActionsPort { try { // Set circuit breaker before retry to prevent recursive handling this.isHandlingQuotaExceeded = true; - // Retry the failed operation after compaction freed space + // Retry the failed operation after compaction freed space. + // #8307 (structural): there is no longer a positional dequeue to + // double-consume — entityChanges is recomputed by the pure, idempotent + // extractEntityChanges() inside writeOperation, so the retry simply + // re-extracts the same changes. Pass isDeferredWrite through unchanged. await this.writeOperation(action, isDeferredWrite, options); this.snackService.open({ type: 'SUCCESS', diff --git a/src/app/op-log/sync/immediate-upload.service.spec.ts b/src/app/op-log/sync/immediate-upload.service.spec.ts index 3b7d69d71e..62cf2a5cff 100644 --- a/src/app/op-log/sync/immediate-upload.service.spec.ts +++ b/src/app/op-log/sync/immediate-upload.service.spec.ts @@ -6,6 +6,7 @@ import { SyncProviderId } from '../sync-providers/provider.const'; import { DataInitStateService } from '../../core/data-init/data-init-state.service'; import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service'; import { SyncSessionValidationService } from './sync-session-validation.service'; +import { SyncCycleGuardService } from './sync-cycle-guard.service'; import { BehaviorSubject } from 'rxjs'; import { RejectedOpInfo } from '../core/types/sync-results.types'; @@ -106,6 +107,10 @@ describe('ImmediateUploadService', () => { }); service = TestBed.inject(ImmediateUploadService); + // The cycle guard is a root singleton; reset it so a prior test that left + // it claimed (e.g. an assertion threw before guard.end()) can't poison this + // one. Mirrors SyncSessionValidationService's per-test reset. + TestBed.inject(SyncCycleGuardService)._resetForTest(); }); afterEach(() => { @@ -189,6 +194,47 @@ describe('ImmediateUploadService', () => { })); }); + // #8309: the immediate-upload side channel must not interleave with another + // sync cycle (main sync, force flow, or WS download). It claims the in-tab + // SyncCycleGuard synchronously at the start of _performUpload and skips if a + // cycle is already active. + describe('sync-cycle guard (#8309)', () => { + it('skips the upload when another sync cycle is active', fakeAsync(() => { + const guard = TestBed.inject(SyncCycleGuardService); + // Simulate another cycle holding the guard (e.g. a WS download). + expect(guard.tryBegin()).toBe(true); + + mockSyncService.uploadPendingOps.and.returnValue( + Promise.resolve(completedResult({ uploadedCount: 1 })), + ); + + service.initialize(); + service.trigger(); + tick(2000); + flush(); + + expect(mockSyncService.uploadPendingOps).not.toHaveBeenCalled(); + + guard.end(); + })); + + it('releases the guard after the upload so a later cycle can run', fakeAsync(() => { + const guard = TestBed.inject(SyncCycleGuardService); + mockSyncService.uploadPendingOps.and.returnValue( + Promise.resolve(completedResult({ uploadedCount: 1 })), + ); + + service.initialize(); + service.trigger(); + tick(2000); + flush(); + + expect(mockSyncService.uploadPendingOps).toHaveBeenCalledTimes(1); + // Guard was released in the finally — a subsequent cycle can claim it. + expect(guard.isActive).toBe(false); + })); + }); + // #7330 follow-up: uploadPendingOps() processes piggybacked remote ops // through validateAfterSync(). Without an explicit withSession() wrapper // the latch flip would either fire outside any session (silently dropped diff --git a/src/app/op-log/sync/immediate-upload.service.ts b/src/app/op-log/sync/immediate-upload.service.ts index 442e18d160..9f3cd1d2de 100644 --- a/src/app/op-log/sync/immediate-upload.service.ts +++ b/src/app/op-log/sync/immediate-upload.service.ts @@ -10,6 +10,7 @@ import { DataInitStateService } from '../../core/data-init/data-init-state.servi import { handleStorageQuotaError } from './sync-error-utils'; import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service'; import { SyncSessionValidationService } from './sync-session-validation.service'; +import { SyncCycleGuardService } from './sync-cycle-guard.service'; const IMMEDIATE_UPLOAD_DEBOUNCE_MS = 2000; @@ -53,6 +54,7 @@ export class ImmediateUploadService implements OnDestroy { private _dataInitStateService = inject(DataInitStateService); private _syncWrapper = inject(SyncWrapperService); private _sessionValidation = inject(SyncSessionValidationService); + private _syncCycleGuard = inject(SyncCycleGuardService); private _uploadTrigger$ = new Subject(); private _subscription: Subscription | null = null; @@ -182,6 +184,25 @@ export class ImmediateUploadService implements OnDestroy { * the immediate-upload path. */ private async _performUpload(): Promise { + // #8309: opportunistically claim the sync cycle. Skip if any cycle (the + // main sync, a force flow, or the WS-download side channel) is already + // active — the running cycle or the next trigger covers this upload, and a + // background upload must not mutate state / flip the session-validation + // latch while another cycle (or its conflict dialog) is open. + if (!this._syncCycleGuard.tryBegin()) { + OpLog.verbose( + 'ImmediateUploadService: Skipping immediate upload — another sync cycle is active', + ); + return; + } + try { + await this._performUploadInner(); + } finally { + this._syncCycleGuard.end(); + } + } + + private async _performUploadInner(): Promise { const provider = this._providerManager.getActiveProvider(); if (!provider) { return; diff --git a/src/app/op-log/sync/sync-cycle-guard.service.spec.ts b/src/app/op-log/sync/sync-cycle-guard.service.spec.ts new file mode 100644 index 0000000000..e6f34f4a13 --- /dev/null +++ b/src/app/op-log/sync/sync-cycle-guard.service.spec.ts @@ -0,0 +1,42 @@ +import { SyncCycleGuardService } from './sync-cycle-guard.service'; + +describe('SyncCycleGuardService', () => { + let guard: SyncCycleGuardService; + + beforeEach(() => { + guard = new SyncCycleGuardService(); + }); + + it('claims the cycle when free and reports active', () => { + expect(guard.isActive).toBe(false); + expect(guard.tryBegin()).toBe(true); + expect(guard.isActive).toBe(true); + }); + + it('returns false without claiming when a cycle is already active', () => { + expect(guard.tryBegin()).toBe(true); + // A second claimant (another entry point) must be told to skip. + expect(guard.tryBegin()).toBe(false); + expect(guard.isActive).toBe(true); + }); + + it('frees the cycle on end()', () => { + expect(guard.tryBegin()).toBe(true); + guard.end(); + expect(guard.isActive).toBe(false); + }); + + it('can be re-acquired after end()', () => { + expect(guard.tryBegin()).toBe(true); + guard.end(); + expect(guard.tryBegin()).toBe(true); + expect(guard.isActive).toBe(true); + }); + + it('_resetForTest clears active state', () => { + expect(guard.tryBegin()).toBe(true); + guard._resetForTest(); + expect(guard.isActive).toBe(false); + expect(guard.tryBegin()).toBe(true); + }); +}); diff --git a/src/app/op-log/sync/sync-cycle-guard.service.ts b/src/app/op-log/sync/sync-cycle-guard.service.ts new file mode 100644 index 0000000000..0b1d52cd1e --- /dev/null +++ b/src/app/op-log/sync/sync-cycle-guard.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; + +/** + * In-tab mutual-exclusion guard for the four top-level sync entry points: + * - `SyncWrapperService.sync()` (periodic / user-triggered full sync) + * - `SyncWrapperService._forceDownload()` (sync-error dialog recovery) + * - `ImmediateUploadService._performUpload()` (side channel) + * - `WsTriggeredDownloadService._downloadOps()` (side channel) + * + * ## Why (#8309) + * These flows share the per-tab {@link SyncSessionValidationService} latch, + * whose single-mutable-boolean design is only safe if at most ONE session is + * active at a time. They also run lock-free seams — the SYNC_IMPORT + * conflict-gate decision (which awaits a user dialog) and `setLastServerSeq` + * persistence — that a concurrent flow can invalidate. The apply phase is + * already serialized by the cross-tab `OPERATION_LOG`/`UPLOAD`/`DOWNLOAD` Web + * Locks; this guard closes the remaining in-tab seams and prevents + * `withSession()` latch misattribution (two overlapping sessions sharing one + * latch). + * + * ## Why a synchronous in-memory skip-guard, not a Web Lock + * - The conflict gate awaits a user dialog. Holding a cross-tab Web Lock across + * that wait would stall other tabs until the 30s lock timeout. + * - Every entry point claims the cycle with {@link tryBegin} *before its first + * `await`*, so the check-and-set is atomic on the single-threaded event loop, + * and skips when a cycle is already active. Skipping (rather than queuing) is + * the correct semantics: an opportunistic side channel must not mutate state + * while another cycle (or its conflict dialog) is open, and a user-triggered + * sync that collides with a short-lived side channel is simply retried on the + * next trigger. Because nothing ever waits on the guard, it cannot deadlock. + * + * Cross-tab apply-phase serialization remains the job of the existing Web + * Locks; cross-tab gate/seq staleness is out of scope for this guard. + */ +@Injectable({ providedIn: 'root' }) +export class SyncCycleGuardService { + private _isActive = false; + + get isActive(): boolean { + return this._isActive; + } + + /** + * Synchronously claim the cycle. Returns `false` (without claiming) if a + * cycle is already active. MUST be called before the caller's first `await` + * so the check-and-set is atomic within the single-threaded event loop. + */ + tryBegin(): boolean { + if (this._isActive) { + return false; + } + this._isActive = true; + return true; + } + + /** Release the cycle. Always call from a `finally` block. */ + end(): void { + this._isActive = false; + } + + /** @internal Test-only reset for the root singleton between unit tests. */ + _resetForTest(): void { + this._isActive = false; + } +} diff --git a/src/app/op-log/sync/ws-triggered-download.service.spec.ts b/src/app/op-log/sync/ws-triggered-download.service.spec.ts index aefa80f0e6..92cc9a2dbf 100644 --- a/src/app/op-log/sync/ws-triggered-download.service.spec.ts +++ b/src/app/op-log/sync/ws-triggered-download.service.spec.ts @@ -9,6 +9,7 @@ import { SyncProviderManager } from '../sync-providers/provider-manager.service' import { WrappedProviderService } from '../sync-providers/wrapped-provider.service'; import { WsTriggeredDownloadService } from './ws-triggered-download.service'; import { SyncSessionValidationService } from './sync-session-validation.service'; +import { SyncCycleGuardService } from './sync-cycle-guard.service'; import { AuthFailSPError, MissingCredentialsSPError } from '../sync-exports'; describe('WsTriggeredDownloadService', () => { @@ -61,6 +62,10 @@ describe('WsTriggeredDownloadService', () => { }); service = TestBed.inject(WsTriggeredDownloadService); + // The cycle guard is a root singleton; reset it so a prior test that left + // it claimed (e.g. an assertion threw before guard.end()) can't poison this + // one. Mirrors SyncSessionValidationService's per-test reset. + TestBed.inject(SyncCycleGuardService)._resetForTest(); }); afterEach(() => { @@ -112,6 +117,37 @@ describe('WsTriggeredDownloadService', () => { expect(mockSyncService.downloadRemoteOps).not.toHaveBeenCalled(); })); + // #8309: the WS-download side channel claims the in-tab SyncCycleGuard and + // skips when another cycle (main sync, force flow, or immediate upload) is + // active, so its gate decision / setLastServerSeq can't race a concurrent + // flow and overlapping withSession() calls can't misattribute the latch. + it('should skip the download when another sync cycle is active (#8309)', fakeAsync(() => { + const guard = TestBed.inject(SyncCycleGuardService); + expect(guard.tryBegin()).toBe(true); + + service.start(); + notification$.next({ latestSeq: 7 }); + tick(500); + flushMicrotasks(); + + expect(mockWrappedProvider.getOperationSyncCapable).not.toHaveBeenCalled(); + expect(mockSyncService.downloadRemoteOps).not.toHaveBeenCalled(); + + guard.end(); + })); + + it('releases the guard after the download so a later cycle can run (#8309)', fakeAsync(() => { + const guard = TestBed.inject(SyncCycleGuardService); + + service.start(); + notification$.next({ latestSeq: 8 }); + tick(500); + flushMicrotasks(); + + expect(mockSyncService.downloadRemoteOps).toHaveBeenCalledTimes(1); + expect(guard.isActive).toBe(false); + })); + it('should stop listening after an auth failure', fakeAsync(() => { mockSyncService.downloadRemoteOps.and.callFake(async () => { throw new AuthFailSPError('unauthorized'); diff --git a/src/app/op-log/sync/ws-triggered-download.service.ts b/src/app/op-log/sync/ws-triggered-download.service.ts index 7d25cb0b99..056548157e 100644 --- a/src/app/op-log/sync/ws-triggered-download.service.ts +++ b/src/app/op-log/sync/ws-triggered-download.service.ts @@ -6,6 +6,7 @@ import { OperationLogSyncService } from './operation-log-sync.service'; import { SyncProviderManager } from '../sync-providers/provider-manager.service'; import { WrappedProviderService } from '../sync-providers/wrapped-provider.service'; import { SyncSessionValidationService } from './sync-session-validation.service'; +import { SyncCycleGuardService } from './sync-cycle-guard.service'; import { SyncLog } from '../../core/log'; import { AuthFailSPError, MissingCredentialsSPError } from '../sync-exports'; @@ -28,6 +29,7 @@ export class WsTriggeredDownloadService implements OnDestroy { private _providerManager = inject(SyncProviderManager); private _wrappedProvider = inject(WrappedProviderService); private _sessionValidation = inject(SyncSessionValidationService); + private _syncCycleGuard = inject(SyncCycleGuardService); private _subscription: Subscription | null = null; @@ -64,6 +66,27 @@ export class WsTriggeredDownloadService implements OnDestroy { return; } + // #8309: claim the sync cycle. Both this check and the isSyncInProgress + // check above are synchronous (no await between), so the claim is atomic. + // Skip if any cycle (main sync, force flow, or the immediate-upload side + // channel) is active — its apply or its conflict dialog must not race this + // download's gate decision / setLastServerSeq, and overlapping withSession() + // calls would misattribute the validation latch. + if (!this._syncCycleGuard.tryBegin()) { + SyncLog.log( + 'WsTriggeredDownloadService: Another sync cycle is active, skipping WS download', + ); + return; + } + + try { + return await this._downloadOpsInner(latestSeq); + } finally { + this._syncCycleGuard.end(); + } + } + + private async _downloadOpsInner(latestSeq: number): Promise { // WS-triggered downloads are their own session boundary. The session // wrapper resets the latch up-front so the read at the end reflects // only this session, and a leaked-failed latch from a prior path can't diff --git a/src/assets/i18n/ar.json b/src/assets/i18n/ar.json index 06d4491904..ce0b09431a 100644 --- a/src/assets/i18n/ar.json +++ b/src/assets/i18n/ar.json @@ -268,7 +268,7 @@ "GET_READY": "قم بالإستعداد لجلسة التركيز", "GOGOGO": "!هيا, هيا, هياااااا!", "LONG_BREAK": "انقطاع طويل", - "LONG_BREAK_TITLE": "استراحة طويلة - دورة {{cycle}}", + "LONG_BREAK_TITLE": "استراحة طويلة", "ON": "نشط", "OPEN_ISSUE_IN_BROWSER": "إفتح مشكلة في المتصفح", "PAUSE_SESSION": "جلسة إيقاف مؤقت", @@ -287,7 +287,7 @@ "SELECT_TASK_TO_FOCUS": "اختر مهمة للتركيز عليها", "SESSION_COMPLETED": "اكتملت جلسة التركيز!", "SHORT_BREAK": "استراحة قصيرة", - "SHORT_BREAK_TITLE": "استراحة قصيرة - دورة {{cycle}}", + "SHORT_BREAK_TITLE": "استراحة قصيرة", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "أظهر / أخفي ملاحظات و مرفقات المهمة", "SKIP_BREAK": "استراحة تخطي", "START_FOCUS_SESSION": "إبدأ جلسة التركيز", diff --git a/src/assets/i18n/cs.json b/src/assets/i18n/cs.json index 9fd6efc2f1..c6a54ac0b7 100644 --- a/src/assets/i18n/cs.json +++ b/src/assets/i18n/cs.json @@ -245,7 +245,7 @@ "GOGOGO": "Jděte, jděte, jděte!!!", "GO_TO_PROCRASTINATION": "Získat pomoc při prokrastinaci", "LONG_BREAK": "Dlouhá přestávka", - "LONG_BREAK_TITLE": "Dlouhá přestávka – cyklus {{cycle}}", + "LONG_BREAK_TITLE": "Dlouhá přestávka", "ON": "zapnuto", "OPEN_ISSUE_IN_BROWSER": "Otevřít problém v prohlížeči", "PAUSE_BREAK": "Pozastavit přestávku", @@ -268,7 +268,7 @@ "SELECT_TASK_TO_FOCUS": "Vyberte úkol k soustředění", "SESSION_COMPLETED": "Relace zaměření dokončena!", "SHORT_BREAK": "Krátká přestávka", - "SHORT_BREAK_TITLE": "Krátká přestávka – cyklus {{cycle}}", + "SHORT_BREAK_TITLE": "Krátká přestávka", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Zobrazit/skrýt poznámky a přílohy k úkolu", "SKIP_BREAK": "Přeskočit přestávku", "START_BREAK": "Spustit přestávku", diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 9f5aff602c..657fb5c0ba 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -330,7 +330,7 @@ "GO_TO_PROCRASTINATION": "Hilfe bei Prokrastination erhalten", "GOGOGO": "Los, los, los!!!", "LONG_BREAK": "Lange Pause", - "LONG_BREAK_TITLE": "Lange Pause - Zyklus {{cycle}}", + "LONG_BREAK_TITLE": "Lange Pause", "ON": "An", "OVERTIME_MSG": "Die Zeit ist um — mach eine Pause, wenn du bereit bist", "OPEN_ISSUE_IN_BROWSER": "Issue im Browser öffnen", @@ -369,7 +369,7 @@ "SELECT_TASK_TO_FOCUS": "Aufgabe zum Fokussieren auswählen", "SESSION_COMPLETED": "Fokussitzung abgeschlossen!", "SHORT_BREAK": "Kurze Pause", - "SHORT_BREAK_TITLE": "Kurze Pause - Zyklus {{cycle}}", + "SHORT_BREAK_TITLE": "Kurze Pause", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Aufgabennotizen und Anhänge ein-/ausblenden", "SKIP_BREAK": "Pause überspringen", "START_BREAK": "Pause starten", diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index b6c34275b5..f69d899bd5 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -278,7 +278,7 @@ "GET_READY": "¡Prepárate para tu sesión de enfoque!", "GOGOGO": "¡Vamos, vamos, vamos!", "LONG_BREAK": "Descanso largo", - "LONG_BREAK_TITLE": "Descanso largo - Ciclo {{cycle}}", + "LONG_BREAK_TITLE": "Descanso largo", "ON": "activado", "OPEN_ISSUE_IN_BROWSER": "Abrir incidencia en el navegador", "PAUSE_SESSION": "Pausar sesión", @@ -298,7 +298,7 @@ "SELECT_TASK_TO_FOCUS": "Seleccionar tarea para enfocarte", "SESSION_COMPLETED": "¡Sesión de enfoque completada!", "SHORT_BREAK": "Descanso corto", - "SHORT_BREAK_TITLE": "Descanso corto - Ciclo {{cycle}}", + "SHORT_BREAK_TITLE": "Descanso corto", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Mostrar/ocultar notas y adjuntos de la tarea", "SKIP_BREAK": "Saltar descanso", "START_BREAK": "Iniciar descanso", diff --git a/src/assets/i18n/fa.json b/src/assets/i18n/fa.json index e80fcca97a..f94f051f3f 100644 --- a/src/assets/i18n/fa.json +++ b/src/assets/i18n/fa.json @@ -228,7 +228,7 @@ "GET_READY": "برای نشست تمرکز خود آماده شوید!", "GOGOGO": "برو، برو، برو!!!", "LONG_BREAK": "وقفه طولانی", - "LONG_BREAK_TITLE": "وقفه طولانی - چرخه {{cycle}}", + "LONG_BREAK_TITLE": "وقفه طولانی", "ON": "در", "OPEN_ISSUE_IN_BROWSER": "موضوع را در مرورگر باز کنید", "PAUSE_SESSION": "جلسه توقف", @@ -247,7 +247,7 @@ "SELECT_TASK_TO_FOCUS": "انتخاب وظیفه برای تمرکز", "SESSION_COMPLETED": "جلسه تمرکز به پایان رسید!", "SHORT_BREAK": "وقفه کوتاه", - "SHORT_BREAK_TITLE": "وقفه کوتاه - چرخه {{cycle}}", + "SHORT_BREAK_TITLE": "وقفه کوتاه", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "نمایش/پنهان کردن یادداشت‌ها و پیوست‌ها", "SKIP_BREAK": "اسکیپ بریک", "START_FOCUS_SESSION": "شروع جلسه تمرکز", diff --git a/src/assets/i18n/fi.json b/src/assets/i18n/fi.json index fb445b5ba9..837ac291cf 100644 --- a/src/assets/i18n/fi.json +++ b/src/assets/i18n/fi.json @@ -233,7 +233,7 @@ "GET_READY": "Valmistaudu keskittymistilaan!", "GOGOGO": "Mennään, mennään, mennään!!!", "LONG_BREAK": "Pitkä tauko", - "LONG_BREAK_TITLE": "Pitkä tauko - Kierros {{cycle}}", + "LONG_BREAK_TITLE": "Pitkä tauko", "ON": "päällä", "OPEN_ISSUE_IN_BROWSER": "Avaa ongelma selaimessa", "PAUSE_SESSION": "Tauota istunto", @@ -253,7 +253,7 @@ "SELECT_TASK_TO_FOCUS": "Valitse keskityttävä tehtävä", "SESSION_COMPLETED": "Keskittymissessio suoritettu!", "SHORT_BREAK": "Lyhyt tauko", - "SHORT_BREAK_TITLE": "Lyhyt tauko - Kierros {{cycle}}", + "SHORT_BREAK_TITLE": "Lyhyt tauko", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Näytä/piilota tehtävän muistiinpanot ja liitteet", "SKIP_BREAK": "Ohita tauko", "START_BREAK": "Aloita tauko", diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index 850ad9f202..32acd36119 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -273,7 +273,7 @@ "GET_READY": "Préparez-vous pour votre session de concentration !!!", "GOGOGO": "Allez! Allez! Allez!!!", "LONG_BREAK": "Pause longue", - "LONG_BREAK_TITLE": "Pause longue - Cycle {{cycle}}", + "LONG_BREAK_TITLE": "Pause longue", "ON": "activé", "OPEN_ISSUE_IN_BROWSER": "Ouvrir le problème dans un navigateur", "PAUSE_SESSION": "Mettre la session en pause", @@ -293,7 +293,7 @@ "SELECT_TASK_TO_FOCUS": "Sélectionner la tâche à concentrer", "SESSION_COMPLETED": "Session de concentration terminée !", "SHORT_BREAK": "Pause courte", - "SHORT_BREAK_TITLE": "Pause courte - Cycle {{cycle}}", + "SHORT_BREAK_TITLE": "Pause courte", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Afficher/masquer les notes et pièces jointes de la tâche", "SKIP_BREAK": "Passer la pause", "START_BREAK": "Commencer la pause", diff --git a/src/assets/i18n/hr.json b/src/assets/i18n/hr.json index d8c30a7d94..0e80bf57a1 100644 --- a/src/assets/i18n/hr.json +++ b/src/assets/i18n/hr.json @@ -285,7 +285,7 @@ "GO_TO_PROCRASTINATION": "Potraži pomoć kad odugovlačiš", "GOGOGO": "Idemoooo!!!", "LONG_BREAK": "Duga pauza", - "LONG_BREAK_TITLE": "Duga pauza – ciklus {{cycle}}", + "LONG_BREAK_TITLE": "Duga pauza", "ON": "na", "OVERTIME_MSG": "Vrijeme je isteklo – uzmi pauzu kad budeš spreman", "OPEN_ISSUE_IN_BROWSER": "Otvori problem u pregledniku", @@ -310,7 +310,7 @@ "SELECT_TASK_TO_FOCUS": "Odaberi zadatak za fokus", "SESSION_COMPLETED": "Fokus sesija je završena!", "SHORT_BREAK": "Kratka pauza", - "SHORT_BREAK_TITLE": "Kratka pauza – ciklus {{cycle}}", + "SHORT_BREAK_TITLE": "Kratka pauza", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Prikaži/sakrij bilješke i privitke zadatka", "SKIP_BREAK": "Preskoči pauzu", "START_BREAK": "Pokreni pauzu", diff --git a/src/assets/i18n/id.json b/src/assets/i18n/id.json index a757220c3f..3e27525ad1 100644 --- a/src/assets/i18n/id.json +++ b/src/assets/i18n/id.json @@ -273,7 +273,7 @@ "GET_READY": "Bersiaplah untuk sesi fokus Anda!", "GOGOGO": "Ayo, ayo, ayo !!!", "LONG_BREAK": "Istirahat Panjang", - "LONG_BREAK_TITLE": "Istirahat Panjang - Siklus {{cycle}}", + "LONG_BREAK_TITLE": "Istirahat Panjang", "ON": "pada", "OPEN_ISSUE_IN_BROWSER": "Masalah terbuka di Browser", "PAUSE_SESSION": "Jeda sesi", @@ -293,7 +293,7 @@ "SELECT_TASK_TO_FOCUS": "Pilih Tugas untuk Difokuskan", "SESSION_COMPLETED": "Sesi Fokus Selesai!", "SHORT_BREAK": "Istirahat Singkat", - "SHORT_BREAK_TITLE": "Istirahat Singkat - Siklus {{cycle}}", + "SHORT_BREAK_TITLE": "Istirahat Singkat", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Menampilkan/menyembunyikan catatan tugas dan lampiran", "SKIP_BREAK": "Lewati Istirahat", "START_BREAK": "Mulai Istirahat", diff --git a/src/assets/i18n/it.json b/src/assets/i18n/it.json index 1cb2e418da..0516c64e74 100644 --- a/src/assets/i18n/it.json +++ b/src/assets/i18n/it.json @@ -273,7 +273,7 @@ "GET_READY": "Preparati per la tua prossima sessione!", "GOGOGO": "Vai, vai, vai!", "LONG_BREAK": "Pausa lunga", - "LONG_BREAK_TITLE": "Pausa lunga - Ciclo {{cycle}}", + "LONG_BREAK_TITLE": "Pausa lunga", "ON": "su", "OPEN_ISSUE_IN_BROWSER": "Apri issue nel Browser", "PAUSE_SESSION": "Metti in pausa la sessione", @@ -293,7 +293,7 @@ "SELECT_TASK_TO_FOCUS": "Seleziona attività su cui concentrarti", "SESSION_COMPLETED": "Sessione di concentrazione completata!", "SHORT_BREAK": "Pausa breve", - "SHORT_BREAK_TITLE": "Pausa breve - Ciclo {{cycle}}", + "SHORT_BREAK_TITLE": "Pausa breve", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Mostra/nascondi note e allegati dell'attività", "SKIP_BREAK": "Salta pausa", "START_BREAK": "Inizia pausa", diff --git a/src/assets/i18n/ja.json b/src/assets/i18n/ja.json index 569eaa8bf6..aa03ff60e2 100644 --- a/src/assets/i18n/ja.json +++ b/src/assets/i18n/ja.json @@ -268,7 +268,7 @@ "GET_READY": "集中セッションの準備をしましょう!", "GOGOGO": "行け、行け、行け!", "LONG_BREAK": "ロング・ブレイク", - "LONG_BREAK_TITLE": "ロングブレイク - サイクル {{cycle}}", + "LONG_BREAK_TITLE": "ロングブレイク", "ON": "オン", "OPEN_ISSUE_IN_BROWSER": "ブラウザで問題を開く", "PAUSE_SESSION": "一時停止セッション", @@ -287,7 +287,7 @@ "SELECT_TASK_TO_FOCUS": "タスクを集中させる", "SESSION_COMPLETED": "フォーカスセッションが完了しました!", "SHORT_BREAK": "ショートブレイク", - "SHORT_BREAK_TITLE": "ショートブレイク - サイクル {{cycle}}", + "SHORT_BREAK_TITLE": "ショートブレイク", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "タスクのメモと添付ファイルを表示/非表示", "SKIP_BREAK": "スキップブレイク", "START_FOCUS_SESSION": "フォーカスセッションを開始", diff --git a/src/assets/i18n/ko.json b/src/assets/i18n/ko.json index 8905cb1207..094ca77e22 100644 --- a/src/assets/i18n/ko.json +++ b/src/assets/i18n/ko.json @@ -269,7 +269,7 @@ "FOCUS_TIME_TOOLTIP": "집중 시간", "GO_TO_PROCRASTINATION": "미루기 도움 받기", "LONG_BREAK": "긴 휴식", - "LONG_BREAK_TITLE": "긴 휴식 - 사이클 {{cycle}}", + "LONG_BREAK_TITLE": "긴 휴식", "PAUSE_BREAK": "휴식 일시정지", "PAUSE_SESSION": "세션 일시정지", "PAUSE_TRACKING": "추적 일시정지", @@ -286,7 +286,7 @@ "SELECT_TASK_FIRST": "추적을 시작하려면 먼저 작업을 선택하세요", "SELECT_TASK_TO_FOCUS": "집중할 작업 선택", "SHORT_BREAK": "짧은 휴식", - "SHORT_BREAK_TITLE": "짧은 휴식 - 사이클 {{cycle}}", + "SHORT_BREAK_TITLE": "짧은 휴식", "SKIP_BREAK": "휴식 건너뛰기", "START_BREAK": "휴식 시작", "SWITCH_TASK": "작업 전환", diff --git a/src/assets/i18n/nb.json b/src/assets/i18n/nb.json index 47911dfaca..d64d20b44d 100644 --- a/src/assets/i18n/nb.json +++ b/src/assets/i18n/nb.json @@ -233,7 +233,7 @@ "GET_READY": "Gjør deg klar for fokusøkten!", "GOGOGO": "Gå, gå, gå!!!", "LONG_BREAK": "Lang pause", - "LONG_BREAK_TITLE": "Lang pause - syklus {{cycle}}", + "LONG_BREAK_TITLE": "Lang pause", "ON": "på", "OPEN_ISSUE_IN_BROWSER": "Åpne problemet i nettleseren", "PAUSE_SESSION": "Sett økten på pause", @@ -253,7 +253,7 @@ "SELECT_TASK_TO_FOCUS": "Velg oppgave å fokusere på", "SESSION_COMPLETED": "Fokusøkt fullført!", "SHORT_BREAK": "Kort pause", - "SHORT_BREAK_TITLE": "Kort pause - syklus {{cycle}}", + "SHORT_BREAK_TITLE": "Kort pause", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Vis/skjul oppgavenotater og vedlegg", "SKIP_BREAK": "Hopp over pause", "START_BREAK": "Start pause", diff --git a/src/assets/i18n/nl.json b/src/assets/i18n/nl.json index 977b3e17e5..0e285cb478 100644 --- a/src/assets/i18n/nl.json +++ b/src/assets/i18n/nl.json @@ -273,7 +273,7 @@ "GET_READY": "Maak je klaar voor je focus sessie!", "GOGOGO": "Go, go, go!!!", "LONG_BREAK": "Lange pauze", - "LONG_BREAK_TITLE": "Lange pauze - Cyclus {{cycle}}", + "LONG_BREAK_TITLE": "Lange pauze", "ON": "aan", "OPEN_ISSUE_IN_BROWSER": "Issue in Browser openen", "PAUSE_SESSION": "Sessie pauzeren", @@ -293,7 +293,7 @@ "SELECT_TASK_TO_FOCUS": "Selecteer taak om op te focussen", "SESSION_COMPLETED": "Focussessie Voltooid!", "SHORT_BREAK": "Korte pauze", - "SHORT_BREAK_TITLE": "Korte pauze - Cyclus {{cycle}}", + "SHORT_BREAK_TITLE": "Korte pauze", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Taaknotities en bijlagen tonen/verbergen", "SKIP_BREAK": "Pauze overslaan", "START_BREAK": "Pauze starten", diff --git a/src/assets/i18n/pl.json b/src/assets/i18n/pl.json index 09564097c6..337f1539ca 100644 --- a/src/assets/i18n/pl.json +++ b/src/assets/i18n/pl.json @@ -278,7 +278,7 @@ "GO_TO_PROCRASTINATION": "Uzyskaj pomoc przeciw prokrastynacji", "GOGOGO": "Dawaj, dawaj, dawaj!!!", "LONG_BREAK": "Długa przerwa", - "LONG_BREAK_TITLE": "Długa przerwa - cykl {{cycle}}", + "LONG_BREAK_TITLE": "Długa przerwa", "ON": "nad zadaniem", "OVERTIME_MSG": "Czas minął — zrób przerwę, gdy będziesz gotów", "OPEN_ISSUE_IN_BROWSER": "Otwórz zgłoszenie w przeglądarce", @@ -303,7 +303,7 @@ "SELECT_TASK_TO_FOCUS": "Wybierz zadanie, na którym chcesz się skupić", "SESSION_COMPLETED": "Sesja skupienia zakończona!", "SHORT_BREAK": "Krótka przerwa", - "SHORT_BREAK_TITLE": "Krótka przerwa - cykl {{cycle}}", + "SHORT_BREAK_TITLE": "Krótka przerwa", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Pokaż/ukryj notatki i załączniki zadania", "SKIP_BREAK": "Pomiń przerwę", "START_BREAK": "Rozpocznij przerwę", diff --git a/src/assets/i18n/pt-br.json b/src/assets/i18n/pt-br.json index 09ac1b239f..2d55d581d7 100644 --- a/src/assets/i18n/pt-br.json +++ b/src/assets/i18n/pt-br.json @@ -278,7 +278,7 @@ "GO_TO_PROCRASTINATION": "Obter ajuda ao procrastinar", "GOGOGO": "Foco total!!!", "LONG_BREAK": "Pausa longa", - "LONG_BREAK_TITLE": "Pausa Longa - Ciclo {{cycle}}", + "LONG_BREAK_TITLE": "Pausa Longa", "ON": "em", "OVERTIME_MSG": "O tempo acabou — faça uma pausa quando estiver pronto", "OPEN_ISSUE_IN_BROWSER": "Abrir issue no navegador", @@ -303,7 +303,7 @@ "SELECT_TASK_TO_FOCUS": "Selecione a tarefa para focar", "SESSION_COMPLETED": "Sessão de foco concluída!", "SHORT_BREAK": "Pausa curta", - "SHORT_BREAK_TITLE": "Pausa curta - ciclo {{cycle}}", + "SHORT_BREAK_TITLE": "Pausa curta", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Mostrar/ocultar notas e anexos da tarefa", "SKIP_BREAK": "Pular pausa", "START_BREAK": "Iniciar pausa", diff --git a/src/assets/i18n/pt.json b/src/assets/i18n/pt.json index 1a9392559e..bc0f38d81b 100644 --- a/src/assets/i18n/pt.json +++ b/src/assets/i18n/pt.json @@ -273,7 +273,7 @@ "GET_READY": "Prepare-se para sua sessão de foco!", "GOGOGO": "Vamos, vamos, vamos!", "LONG_BREAK": "Pausa Longa", - "LONG_BREAK_TITLE": "Pausa Longa - Ciclo {{cycle}}", + "LONG_BREAK_TITLE": "Pausa Longa", "ON": "Ativado", "OPEN_ISSUE_IN_BROWSER": "Abrir tarefa em um navegador", "PAUSE_SESSION": "Pausar sessão", @@ -293,7 +293,7 @@ "SELECT_TASK_TO_FOCUS": "Selecionar Tarefa para Focar", "SESSION_COMPLETED": "Sessão de Foco Concluída!", "SHORT_BREAK": "Pausa Curta", - "SHORT_BREAK_TITLE": "Pausa Curta - Ciclo {{cycle}}", + "SHORT_BREAK_TITLE": "Pausa Curta", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Mostrar/ocultar notas e anexos da tarefa", "SKIP_BREAK": "Pular Pausa", "START_BREAK": "Iniciar Pausa", diff --git a/src/assets/i18n/ro-md.json b/src/assets/i18n/ro-md.json index b0f99db7c3..99fe66332b 100644 --- a/src/assets/i18n/ro-md.json +++ b/src/assets/i18n/ro-md.json @@ -285,7 +285,7 @@ "GO_TO_PROCRASTINATION": "Obține ajutor cînd amîni sarcinile", "GOGOGO": "Dă-i, dă-i, dă-i!!!", "LONG_BREAK": "Pauză lungă", - "LONG_BREAK_TITLE": "Pauză lungă - Ciclu {{cycle}}", + "LONG_BREAK_TITLE": "Pauză lungă", "ON": "activat", "OVERTIME_MSG": "Timpul a expirat — ia o pauză cînd ești pregătit", "OPEN_ISSUE_IN_BROWSER": "Deschide problema în navigatorul web", @@ -310,7 +310,7 @@ "SELECT_TASK_TO_FOCUS": "Alege sarcina pe care vrei să te concentrezi", "SESSION_COMPLETED": "Sesiunea de concentrare s-a încheiat!", "SHORT_BREAK": "Pauză scurtă", - "SHORT_BREAK_TITLE": "Pauză scurtă - ciclu {{cycle}}", + "SHORT_BREAK_TITLE": "Pauză scurtă", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Afișează/ascunde notițele și atașamentele sarcinii", "SKIP_BREAK": "Sari peste pauză", "START_BREAK": "Începe pauza", diff --git a/src/assets/i18n/ro.json b/src/assets/i18n/ro.json index 602e3d3dae..261c96d22a 100644 --- a/src/assets/i18n/ro.json +++ b/src/assets/i18n/ro.json @@ -285,7 +285,7 @@ "GO_TO_PROCRASTINATION": "Obține ajutor când amâni sarcinile", "GOGOGO": "Dă-i, dă-i, dă-i!!!", "LONG_BREAK": "Pauză lungă", - "LONG_BREAK_TITLE": "Pauză lungă - Ciclu {{cycle}}", + "LONG_BREAK_TITLE": "Pauză lungă", "ON": "activat", "OVERTIME_MSG": "Timpul a expirat — ia o pauză când ești pregătit", "OPEN_ISSUE_IN_BROWSER": "Deschide problema în navigatorul web", @@ -310,7 +310,7 @@ "SELECT_TASK_TO_FOCUS": "Alege sarcina pe care vrei să te concentrezi", "SESSION_COMPLETED": "Sesiunea de concentrare s-a încheiat!", "SHORT_BREAK": "Pauză scurtă", - "SHORT_BREAK_TITLE": "Pauză scurtă - ciclu {{cycle}}", + "SHORT_BREAK_TITLE": "Pauză scurtă", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Afișează/ascunde notițele și atașamentele sarcinii", "SKIP_BREAK": "Sari peste pauză", "START_BREAK": "Începe pauza", diff --git a/src/assets/i18n/ru.json b/src/assets/i18n/ru.json index b1bae43019..da72918332 100644 --- a/src/assets/i18n/ru.json +++ b/src/assets/i18n/ru.json @@ -273,7 +273,7 @@ "GET_READY": "Приготовьтесь к своему фокус-сеансу!", "GOGOGO": "Вперёд, вперёд, вперёд!!!", "LONG_BREAK": "Длинный перерыв", - "LONG_BREAK_TITLE": "Длинный перерыв - цикл {{cycle}}", + "LONG_BREAK_TITLE": "Длинный перерыв", "ON": "на", "OPEN_ISSUE_IN_BROWSER": "Открыть проблему в Браузере", "PAUSE_SESSION": "Пауза сессии", @@ -293,7 +293,7 @@ "SELECT_TASK_TO_FOCUS": "Выберите задачу для фокуса", "SESSION_COMPLETED": "Сессия фокуса завершена!", "SHORT_BREAK": "Короткий перерыв", - "SHORT_BREAK_TITLE": "Короткий перерыв - цикл {{cycle}}", + "SHORT_BREAK_TITLE": "Короткий перерыв", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Показать/скрыть заметки о задачах и вложениях", "SKIP_BREAK": "Пропустить перерыв", "START_BREAK": "Начать перерыв", diff --git a/src/assets/i18n/sk.json b/src/assets/i18n/sk.json index 14cb7df2a1..f49440e9b4 100644 --- a/src/assets/i18n/sk.json +++ b/src/assets/i18n/sk.json @@ -233,7 +233,7 @@ "GET_READY": "Pripravte sa na sústredenie!", "GOGOGO": "Choď, choď, choď!!!", "LONG_BREAK": "Dlhá prestávka", - "LONG_BREAK_TITLE": "Dlhá prestávka - cyklus {{cycle}}", + "LONG_BREAK_TITLE": "Dlhá prestávka", "ON": "na stránke .", "OPEN_ISSUE_IN_BROWSER": "Otvorenie problému v prehliadači", "PAUSE_SESSION": "Pozastaviť reláciu", @@ -253,7 +253,7 @@ "SELECT_TASK_TO_FOCUS": "Vyberte úlohu na zameranie", "SESSION_COMPLETED": "Relácia zamerania dokončená!", "SHORT_BREAK": "Krátka prestávka", - "SHORT_BREAK_TITLE": "Krátka prestávka - cyklus {{cycle}}", + "SHORT_BREAK_TITLE": "Krátka prestávka", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Zobrazenie/skrytie poznámok k úlohám a príloh", "SKIP_BREAK": "Preskočiť prestávku", "START_BREAK": "Začať prestávku", diff --git a/src/assets/i18n/sv.json b/src/assets/i18n/sv.json index 6ecb878f10..29d20c9506 100644 --- a/src/assets/i18n/sv.json +++ b/src/assets/i18n/sv.json @@ -268,8 +268,8 @@ "FOCUS_TIME_TOOLTIP": "Fokuseringstid", "LONG_BREAK": "Lång paus", "SHORT_BREAK": "Kort paus", - "LONG_BREAK_TITLE": "Lång paus - Cykel {{cycle}}", - "SHORT_BREAK_TITLE": "Kort paus - Cykel {{cycle}}", + "LONG_BREAK_TITLE": "Lång paus", + "SHORT_BREAK_TITLE": "Kort paus", "GO_TO_PROCRASTINATION": "Få hjälp vid prokrastinering", "PAUSE_BREAK": "Pausa rast", "POMODORO_SETTINGS": "Pomodoro-inställningar", diff --git a/src/assets/i18n/tr.json b/src/assets/i18n/tr.json index 928e612a80..279ce91888 100644 --- a/src/assets/i18n/tr.json +++ b/src/assets/i18n/tr.json @@ -291,7 +291,7 @@ "GO_TO_PROCRASTINATION": "Ertelediğinde yardım al", "GOGOGO": "Hadi hadi hadi!!!", "LONG_BREAK": "Uzun Mola", - "LONG_BREAK_TITLE": "Uzun Mola - Döngü {{cycle}}", + "LONG_BREAK_TITLE": "Uzun Mola", "ON": "on", "OVERTIME_MSG": "Süre doldu — hazır olduğunuzda ara verin", "OPEN_ISSUE_IN_BROWSER": "Sorunu Tarayıcıda aç", @@ -330,7 +330,7 @@ "SELECT_TASK_TO_FOCUS": "Odaklanmak için Görevi seçin", "SESSION_COMPLETED": "Odak Oturumu Tamamlandı!", "SHORT_BREAK": "Kısa Mola", - "SHORT_BREAK_TITLE": "Kısa Mola - Döngü {{cycle}}", + "SHORT_BREAK_TITLE": "Kısa Mola", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Görev notlarını ve eklerini göster/gizle", "SKIP_BREAK": "Molayı Atla", "START_BREAK": "Mola başlat", diff --git a/src/assets/i18n/uk.json b/src/assets/i18n/uk.json index 179989de1b..7915982b64 100644 --- a/src/assets/i18n/uk.json +++ b/src/assets/i18n/uk.json @@ -245,7 +245,7 @@ "GOGOGO": "Вперед, вперед, вперед!!!", "GO_TO_PROCRASTINATION": "Отримати допомогу при прокрастинації", "LONG_BREAK": "Довга перерва", - "LONG_BREAK_TITLE": "Довга перерва - Цикл {{cycle}}", + "LONG_BREAK_TITLE": "Довга перерва", "ON": "на", "OPEN_ISSUE_IN_BROWSER": "Відкрити завдання в браузері", "PAUSE_BREAK": "Призупинити перерву", @@ -268,7 +268,7 @@ "SELECT_TASK_TO_FOCUS": "Виберіть завдання для фокусу", "SESSION_COMPLETED": "Фокус-сесія завершена!", "SHORT_BREAK": "Коротка перерва", - "SHORT_BREAK_TITLE": "Коротка перерва - цикл {{cycle}}", + "SHORT_BREAK_TITLE": "Коротка перерва", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Показати/приховати нотатки та вкладення завдання", "SKIP_BREAK": "Пропустити перерву", "START_BREAK": "Почати перерву", diff --git a/src/assets/i18n/vi.json b/src/assets/i18n/vi.json index f0e9013adc..abfa618aed 100644 --- a/src/assets/i18n/vi.json +++ b/src/assets/i18n/vi.json @@ -290,7 +290,7 @@ "GO_TO_PROCRASTINATION": "Nhận giúp đỡ khi trì hoãn", "GOGOGO": "Đi nào, đi nào, đi nào!!!", "LONG_BREAK": "Nghỉ dài", - "LONG_BREAK_TITLE": "Nghỉ dài - Chu kỳ {{cycle}}", + "LONG_BREAK_TITLE": "Nghỉ dài", "ON": "trên", "OVERTIME_MSG": "Hết giờ — hãy nghỉ khi sẵn sàng", "OPEN_ISSUE_IN_BROWSER": "Mở sự cố trong trình duyệt web", @@ -329,7 +329,7 @@ "SELECT_TASK_TO_FOCUS": "Chọn công việc để tập trung", "SESSION_COMPLETED": "Phiên tập trung đã hoàn thành!", "SHORT_BREAK": "Nghỉ ngắn", - "SHORT_BREAK_TITLE": "Nghỉ ngắn - chu kỳ {{cycle}}", + "SHORT_BREAK_TITLE": "Nghỉ ngắn", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "Hiện/ẩn ghi chú và tệp đính kèm công việc", "SKIP_BREAK": "Bỏ qua nghỉ", "START_BREAK": "Bắt đầu nghỉ", diff --git a/src/assets/i18n/zh-tw.json b/src/assets/i18n/zh-tw.json index 3828a255ac..b27feb1e82 100644 --- a/src/assets/i18n/zh-tw.json +++ b/src/assets/i18n/zh-tw.json @@ -279,7 +279,7 @@ "GO_TO_PROCRASTINATION": "取得抗拖延協助", "GOGOGO": "加油,加油,加油!!!", "LONG_BREAK": "長休息", - "LONG_BREAK_TITLE": "長休息 - 循環 {{cycle}}", + "LONG_BREAK_TITLE": "長休息", "ON": "於", "OPEN_ISSUE_IN_BROWSER": "在瀏覽器中開啟議題", "PAUSE_BREAK": "暫停休息", @@ -303,7 +303,7 @@ "SELECT_TASK_TO_FOCUS": "選擇要專注的工作", "SESSION_COMPLETED": "專注時段完成!", "SHORT_BREAK": "短休息", - "SHORT_BREAK_TITLE": "短休息 - 循環 {{cycle}}", + "SHORT_BREAK_TITLE": "短休息", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "顯示/隱藏工作筆記和附件", "SKIP_BREAK": "跳過休息", "START_BREAK": "開始休息", diff --git a/src/assets/i18n/zh.json b/src/assets/i18n/zh.json index ee361c605d..b515ab1101 100644 --- a/src/assets/i18n/zh.json +++ b/src/assets/i18n/zh.json @@ -285,7 +285,7 @@ "GOGOGO": "冲,冲,冲!!!", "GO_TO_PROCRASTINATION": "获取抗拖延帮助", "LONG_BREAK": "长休息", - "LONG_BREAK_TITLE": "长休息 - 第 {{cycle}} 轮", + "LONG_BREAK_TITLE": "长休息", "ON": "在", "OPEN_ISSUE_IN_BROWSER": "在浏览器中打开问题", "PAUSE_BREAK": "暂停休息", @@ -308,7 +308,7 @@ "SELECT_TASK_TO_FOCUS": "选择要专注的任务", "SESSION_COMPLETED": "专注会话已完成!", "SHORT_BREAK": "短休息", - "SHORT_BREAK_TITLE": "短休息 - 第 {{cycle}} 轮", + "SHORT_BREAK_TITLE": "短休息", "SHOW_HIDE_NOTES_AND_ATTACHMENTS": "显示/隐藏任务备注和附件", "SKIP_BREAK": "跳过休息", "START_BREAK": "开始休息",