mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
fix(sync): handle data validation errors and improve client ID management
- Separate JsonParseError and SyncDataCorruptedError handlers in sync wrapper - Handle corrupted remote data gracefully instead of throwing - Add getOrGenerateClientId() as unified entry point, eliminating dual injection of ClientIdService + CLIENT_ID_PROVIDER in snapshot-upload, file-based-encryption, and sync-hydration services - Use crypto.getRandomValues() instead of Math.random() for client ID generation - Warn user when stored client ID is invalid and must be regenerated - Fix flaky e2e supersync tests (premature waitForURL match on daily-summary URL)
This commit is contained in:
parent
81788143b6
commit
0e9218bd68
87 changed files with 1875 additions and 3372 deletions
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
|
||||
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
|
|
|
|||
2
.github/workflows/build-android.yml
vendored
2
.github/workflows/build-android.yml
vendored
|
|
@ -77,7 +77,7 @@ jobs:
|
|||
# APK is now signed automatically by Gradle using signingConfig
|
||||
|
||||
- name: 'Upload APK files'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: sup-android-release
|
||||
path: android/app/build/outputs/apk/**/*.apk
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ jobs:
|
|||
github_token: ${{ secrets.github_token }}
|
||||
|
||||
- name: 'Upload Artifact'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: WinStoreRelease
|
||||
path: .tmp/app-builds/*.appx
|
||||
|
|
|
|||
2
.github/workflows/build-ios.yml
vendored
2
.github/workflows/build-ios.yml
vendored
|
|
@ -200,7 +200,7 @@ jobs:
|
|||
run: ls -la "$RUNNER_TEMP/ipa-output"
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: sup-ios-release
|
||||
path: ${{ runner.temp }}/ipa-output/*.ipa
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
|
||||
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
|
||||
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
run: npm run buildFrontend:prodWeb
|
||||
|
||||
- name: Deploy to Web Server
|
||||
uses: easingthemes/ssh-deploy@2cc5b27bf3029d0455dd5e09fe02633904031447 # v6.0.3
|
||||
uses: easingthemes/ssh-deploy@a1aa0b6cf96ce2406eef90faa35007a4a7bf0ac0 # v5.1.1
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.WEB_SERVER_SSH_KEY }}
|
||||
ARGS: '-rltgoDzvO --delete --exclude "news.json"'
|
||||
|
|
|
|||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
fi
|
||||
} > /tmp/release-body.md
|
||||
- name: Create Draft Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: true
|
||||
|
|
@ -102,7 +102,7 @@ jobs:
|
|||
|
||||
- name: 'Upload E2E results on failure'
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2eResults
|
||||
path: .tmp/e2e-test-results/**/*.*
|
||||
|
|
@ -314,7 +314,7 @@ jobs:
|
|||
|
||||
- name: Upload unsigned executables for SignPath
|
||||
id: upload-unsigned
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: unsigned-windows-executables
|
||||
path: .tmp/app-builds/*.exe
|
||||
|
|
@ -322,7 +322,7 @@ jobs:
|
|||
retention-days: 1
|
||||
|
||||
- name: Sign Windows executables with SignPath
|
||||
uses: signpath/github-action-submit-signing-request@bc66d86b015a46e9c6d9700de73143a82f9570ff # v2
|
||||
uses: signpath/github-action-submit-signing-request@3f9250c56651ff692d6729a2fbb0603a42d7d322 # v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
|
|
@ -474,7 +474,7 @@ jobs:
|
|||
"
|
||||
|
||||
- name: Publish signed Windows binaries to GitHub Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: |
|
||||
|
|
|
|||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -120,7 +120,7 @@ jobs:
|
|||
run: npm run e2e:ci
|
||||
- name: 'Upload E2E results on failure'
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2eResults
|
||||
path: .tmp/e2e-test-results/**/*.*
|
||||
|
|
|
|||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1
|
||||
uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
8
.github/workflows/e2e-scheduled.yml
vendored
8
.github/workflows/e2e-scheduled.yml
vendored
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
test -f .tmp/angular-dist/browser/assets/bundled-plugins/api-test-plugin/manifest.json || (echo "Plugin assets missing from build" && exit 1)
|
||||
|
||||
- name: Upload Build Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-build-${{ github.run_id }}
|
||||
path: .tmp/angular-dist
|
||||
|
|
@ -86,7 +86,7 @@ jobs:
|
|||
|
||||
- name: Upload E2E Results on Failure
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-results-regular-${{ github.run_id }}
|
||||
path: .tmp/e2e-test-results/**/*.*
|
||||
|
|
@ -143,7 +143,7 @@ jobs:
|
|||
|
||||
- name: Upload E2E Results on Failure
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-results-webdav-${{ github.run_id }}
|
||||
path: .tmp/e2e-test-results/**/*.*
|
||||
|
|
@ -205,7 +205,7 @@ jobs:
|
|||
|
||||
- name: Upload E2E Results on Failure
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-results-supersync-${{ matrix.shard }}-${{ github.run_id }}
|
||||
path: .tmp/e2e-test-results/**/*.*
|
||||
|
|
|
|||
4
.github/workflows/manual-build.yml
vendored
4
.github/workflows/manual-build.yml
vendored
|
|
@ -62,7 +62,7 @@ jobs:
|
|||
release: false
|
||||
|
||||
- name: 'Upload Artifact'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: WinBuildStuff
|
||||
path: .tmp/app-builds/*.exe
|
||||
|
|
@ -162,7 +162,7 @@ jobs:
|
|||
# if: always()
|
||||
# run: ls -la && cat notarization-error.log
|
||||
- name: 'Upload Artifact'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: dmg
|
||||
path: .tmp/app-builds/*.dmg
|
||||
|
|
|
|||
2
.github/workflows/pr-preview-build.yml
vendored
2
.github/workflows/pr-preview-build.yml
vendored
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
run: npm run buildFrontend:prodWeb
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: pr-preview-build
|
||||
path: dist/browser
|
||||
|
|
|
|||
4
.github/workflows/publish-to-hub-docker.yml
vendored
4
.github/workflows/publish-to-hub-docker.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
|
||||
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
|
|
|||
4
.github/workflows/supersync-docker.yml
vendored
4
.github/workflows/supersync-docker.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
|
||||
uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
|
|
@ -59,7 +59,7 @@ jobs:
|
|||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./packages/super-sync-server/Dockerfile
|
||||
|
|
|
|||
2
.github/workflows/test-mac-dmg-build.yml
vendored
2
.github/workflows/test-mac-dmg-build.yml
vendored
|
|
@ -117,7 +117,7 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Upload DMG artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: mac-dmg-build
|
||||
path: .tmp/app-builds/*.dmg
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ package com.superproductivity.superproductivity.plugins
|
|||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Process
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
|
|
@ -42,26 +40,18 @@ class SafBridgePlugin : Plugin() {
|
|||
@ActivityCallback
|
||||
private fun handleFolderSelectionResult(call: PluginCall, result: ActivityResult) {
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val resultData = result.data
|
||||
val uri = resultData?.data
|
||||
val uri = result.data?.data
|
||||
if (uri != null) {
|
||||
// Use only the flags that were actually granted by the result intent.
|
||||
// Applying flags not present in the result throws SecurityException on some devices.
|
||||
// resultData is Intent? but uri != null proves resultData is non-null here;
|
||||
// Kotlin cannot infer this transitively, so !! is required and safe.
|
||||
val grantedFlags = resultData!!.flags and
|
||||
(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
// Take persistable permission
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(uri, grantedFlags)
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
} catch (e: SecurityException) {
|
||||
// Some devices/OEM ROMs fail to persist SAF permission even when
|
||||
// FLAG_GRANT_PERSISTABLE_URI_PERMISSION was requested. Fall through and
|
||||
// still return the URI — it will work for this session even if it can't
|
||||
// be persisted across reboots (user will need to re-select next launch).
|
||||
android.util.Log.w(
|
||||
"SafBridgePlugin",
|
||||
"Could not persist URI permission (session-only access): ${e.message}",
|
||||
)
|
||||
// Some devices/Android versions fail to persist SAF permission
|
||||
android.util.Log.w("SafBridgePlugin", "Failed to persist URI permission: ${e.message}")
|
||||
call.reject("Failed to persist folder permission: ${e.message}")
|
||||
return
|
||||
}
|
||||
|
||||
val ret = JSObject()
|
||||
|
|
@ -218,19 +208,8 @@ class SafBridgePlugin : Plugin() {
|
|||
|
||||
try {
|
||||
val uri = Uri.parse(uriString)
|
||||
// Check both persistent grants (survive reboots) and temporary session grants
|
||||
// (active for current app session only). This is required to support devices/OEM
|
||||
// ROMs where takePersistableUriPermission throws SecurityException — on those
|
||||
// devices the session grant is still valid even though it could not be persisted.
|
||||
val pid = Process.myPid()
|
||||
val uid = Process.myUid()
|
||||
val canRead = context.checkUriPermission(
|
||||
uri, pid, uid, Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val canWrite = context.checkUriPermission(
|
||||
uri, pid, uid, Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val hasPermission = canRead && canWrite
|
||||
val persistedUris = context.contentResolver.persistedUriPermissions
|
||||
val hasPermission = persistedUris.any { it.uri == uri && it.isReadPermission && it.isWritePermission }
|
||||
|
||||
val ret = JSObject()
|
||||
ret.put("hasPermission", hasPermission)
|
||||
|
|
|
|||
|
|
@ -1,513 +0,0 @@
|
|||
# Location-Based Reminders — Design Document
|
||||
|
||||
> **Status: Planned (Brainstorm)**
|
||||
|
||||
## Overview
|
||||
|
||||
Add location-based reminders to Super Productivity. Users can attach a saved location to a task and receive a notification when they arrive at that place. This is primarily a mobile feature (Android/iOS via Capacitor), with passive location display on desktop/web.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Primary platform | Mobile (Android/iOS) | Geofencing requires GPS + background location. Desktop/web show location info but don't trigger. |
|
||||
| Trigger type | Arrive only (v1) | Simplest. Leave triggers can be added later. |
|
||||
| Location management | Saved locations entity | Users revisit the same places. Follows existing Tag entity pattern. |
|
||||
| Location picker (v1) | "Use current location" + label | No map needed for v1. Map picker is a v2 enhancement. |
|
||||
| Geofencing approach | Custom native Capacitor plugin | `@capacitor/geolocation` only does point-in-time reads, not geofencing. Need native `GeofencingClient` (Android) / `CLLocationManager` (iOS). |
|
||||
| Sync | Sync by default | Location data treated like any other entity. Each device manages its own geofences locally after sync. |
|
||||
| Feature toggle | `isLocationRemindersEnabled` in `AppFeaturesConfig` | Opt-in, default false. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model
|
||||
|
||||
### 1.1 SavedLocation Entity
|
||||
|
||||
New file: `src/app/features/saved-location/saved-location.model.ts`
|
||||
|
||||
```typescript
|
||||
import { EntityState } from '@ngrx/entity';
|
||||
|
||||
export interface SavedLocationCopy {
|
||||
id: string;
|
||||
title: string; // "Office", "Grocery Store", "Gym"
|
||||
lat: number; // latitude
|
||||
lng: number; // longitude
|
||||
radius: number; // geofence radius in meters (default 200)
|
||||
icon?: string | null; // material icon name, e.g. 'home', 'work', 'shopping_cart'
|
||||
created: number; // creation timestamp
|
||||
modified?: number; // last update timestamp
|
||||
}
|
||||
|
||||
export type SavedLocation = Readonly<SavedLocationCopy>;
|
||||
export type SavedLocationState = EntityState<SavedLocation>;
|
||||
```
|
||||
|
||||
### 1.2 Task Model Changes
|
||||
|
||||
File: `src/app/features/tasks/task.model.ts` — add to `TaskCopy`:
|
||||
|
||||
```typescript
|
||||
/** ID of a SavedLocation. When set, a geofence reminder is active for this task. */
|
||||
locationReminderId?: string | null;
|
||||
```
|
||||
|
||||
### 1.3 Config Changes
|
||||
|
||||
File: `src/app/features/config/global-config.model.ts` — add to `AppFeaturesConfig`:
|
||||
|
||||
```typescript
|
||||
isLocationRemindersEnabled: boolean; // default false
|
||||
```
|
||||
|
||||
File: `src/app/features/config/default-global-config.const.ts` — set default:
|
||||
|
||||
```typescript
|
||||
isLocationRemindersEnabled: false,
|
||||
```
|
||||
|
||||
### 1.4 Platform Capabilities
|
||||
|
||||
File: `src/app/core/platform/platform-capabilities.model.ts` — add:
|
||||
|
||||
```typescript
|
||||
/** Whether the platform supports native geofencing (background location monitoring). */
|
||||
readonly geofencing: boolean;
|
||||
```
|
||||
|
||||
| Platform | `geofencing` |
|
||||
|----------|-------------|
|
||||
| Android (`ANDROID_CAPABILITIES`) | `true` |
|
||||
| iOS (`IOS_CAPABILITIES`) | `true` |
|
||||
| Electron (`ELECTRON_CAPABILITIES`) | `false` |
|
||||
| Web (`WEB_CAPABILITIES`) | `false` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Sync & Persistence Registration
|
||||
|
||||
All registration steps required for the new entity, following existing patterns:
|
||||
|
||||
### 2.1 Entity Type
|
||||
|
||||
File: `packages/shared-schema/src/entity-types.ts`
|
||||
|
||||
Add `'SAVED_LOCATION'` to the `ENTITY_TYPES` array.
|
||||
|
||||
### 2.2 Action Types Enum
|
||||
|
||||
File: `src/app/op-log/core/action-types.enum.ts`
|
||||
|
||||
```typescript
|
||||
// SavedLocation actions
|
||||
SAVED_LOCATION_ADD = '[SavedLocation] Add SavedLocation',
|
||||
SAVED_LOCATION_UPDATE = '[SavedLocation] Update SavedLocation',
|
||||
SAVED_LOCATION_DELETE = '[SavedLocation] Delete SavedLocation',
|
||||
```
|
||||
|
||||
These string values are **immutable** once deployed — they are used for encoding/decoding operations in IndexedDB and sync between clients.
|
||||
|
||||
### 2.3 Entity Registry
|
||||
|
||||
File: `src/app/op-log/core/entity-registry.ts`
|
||||
|
||||
Add to `ENTITY_CONFIGS`:
|
||||
|
||||
```typescript
|
||||
SAVED_LOCATION: {
|
||||
storagePattern: 'adapter',
|
||||
featureName: SAVED_LOCATION_FEATURE_NAME,
|
||||
payloadKey: 'savedLocation',
|
||||
adapter: savedLocationAdapter,
|
||||
selectEntities: createSelector(
|
||||
selectSavedLocationFeatureState,
|
||||
selectSavedLocationEntitiesFromAdapter,
|
||||
),
|
||||
selectById: selectSavedLocationById,
|
||||
},
|
||||
```
|
||||
|
||||
### 2.4 Model Config
|
||||
|
||||
File: `src/app/op-log/model/model-config.ts`
|
||||
|
||||
Add to `AllModelConfig` type:
|
||||
|
||||
```typescript
|
||||
savedLocation: ModelCfg<SavedLocationState>;
|
||||
```
|
||||
|
||||
Add to `MODEL_CONFIGS`:
|
||||
|
||||
```typescript
|
||||
savedLocation: {
|
||||
defaultData: initialSavedLocationState,
|
||||
isMainFileModel: true,
|
||||
repair: fixEntityStateConsistency,
|
||||
},
|
||||
```
|
||||
|
||||
### 2.5 Root State
|
||||
|
||||
File: `src/app/root-store/root-state.ts`
|
||||
|
||||
```typescript
|
||||
[SAVED_LOCATION_FEATURE_NAME]: SavedLocationState;
|
||||
```
|
||||
|
||||
### 2.6 Feature Store Registration
|
||||
|
||||
File: `src/app/root-store/feature-stores.module.ts`
|
||||
|
||||
```typescript
|
||||
StoreModule.forFeature(SAVED_LOCATION_FEATURE_NAME, savedLocationReducer),
|
||||
```
|
||||
|
||||
### 2.7 Cascading Delete Meta-Reducer
|
||||
|
||||
File: `src/app/root-store/meta/task-shared-meta-reducers/saved-location-shared.reducer.ts`
|
||||
|
||||
When a SavedLocation is deleted, clear `locationReminderId` on all tasks that reference it. This must be a meta-reducer (not an effect) to ensure atomicity — one operation in the sync log.
|
||||
|
||||
Register in `src/app/root-store/meta/meta-reducer-registry.ts` in **Phase 5** (Entity-Specific Cascades), alongside `tagSharedMetaReducer`, `projectSharedMetaReducer`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 3. NgRx Store
|
||||
|
||||
### 3.1 Actions
|
||||
|
||||
File: `src/app/features/saved-location/store/saved-location.actions.ts`
|
||||
|
||||
Following the Tag action pattern with `PersistentActionMeta`:
|
||||
|
||||
| Action | OpType | Payload |
|
||||
|--------|--------|---------|
|
||||
| `addSavedLocation` | `Create` | `{ savedLocation: SavedLocation }` |
|
||||
| `updateSavedLocation` | `Update` | `{ savedLocation: Update<SavedLocation> }` |
|
||||
| `deleteSavedLocation` | `Delete` | `{ id: string }` |
|
||||
|
||||
All include `meta: { isPersistent: true, entityType: 'SAVED_LOCATION', entityId, opType } satisfies PersistentActionMeta`.
|
||||
|
||||
### 3.2 Reducer
|
||||
|
||||
File: `src/app/features/saved-location/store/saved-location.reducer.ts`
|
||||
|
||||
Standard `@ngrx/entity` adapter:
|
||||
|
||||
```typescript
|
||||
export const SAVED_LOCATION_FEATURE_NAME = 'savedLocation';
|
||||
export const savedLocationAdapter = createEntityAdapter<SavedLocation>({
|
||||
sortComparer: (a, b) => a.title.localeCompare(b.title),
|
||||
});
|
||||
export const initialSavedLocationState = savedLocationAdapter.getInitialState();
|
||||
```
|
||||
|
||||
### 3.3 Selectors
|
||||
|
||||
File: `src/app/features/saved-location/store/saved-location.selectors.ts`
|
||||
|
||||
| Selector | Returns |
|
||||
|----------|---------|
|
||||
| `selectSavedLocationFeatureState` | Feature state |
|
||||
| `selectAllSavedLocations` | `SavedLocation[]` |
|
||||
| `selectSavedLocationById` | `SavedLocation \| undefined` |
|
||||
| `selectSavedLocationEntities` | `Dictionary<SavedLocation>` |
|
||||
|
||||
Task-side selector (in task selectors or a cross-feature selector):
|
||||
|
||||
| Selector | Returns |
|
||||
|----------|---------|
|
||||
| `selectTasksWithLocationReminder` | All undone tasks that have `locationReminderId` set |
|
||||
|
||||
### 3.4 Service
|
||||
|
||||
File: `src/app/features/saved-location/saved-location.service.ts`
|
||||
|
||||
Thin wrapper dispatching actions to the store. Methods: `addSavedLocation()`, `updateSavedLocation()`, `deleteSavedLocation()`, `getById$()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Geofencing Service
|
||||
|
||||
### 4.1 Architecture
|
||||
|
||||
New file: `src/app/features/saved-location/geofence.service.ts`
|
||||
|
||||
```
|
||||
Store (undone tasks with locationReminderId)
|
||||
→ GeofenceService watches selector (distinctUntilChanged)
|
||||
→ Computes which locations need active geofences
|
||||
→ Registers/unregisters via custom Capacitor plugin
|
||||
→ Receives geofence enter events
|
||||
→ Emits to ReminderService for dialog/notification
|
||||
```
|
||||
|
||||
### 4.2 Why a Custom Capacitor Plugin
|
||||
|
||||
`@capacitor/geolocation` only provides one-time and continuous position reads — it does **not** support geofencing. Native geofencing requires:
|
||||
|
||||
- **Android:** `com.google.android.gms.location.GeofencingClient` (Google Play Services). Supports up to 100 geofences. Fires `BroadcastReceiver` on enter/exit.
|
||||
- **iOS:** `CLLocationManager.startMonitoring(for: CLCircularRegion)`. Supports up to 20 monitored regions. Fires delegate callbacks on enter/exit.
|
||||
|
||||
Implementation: Create `GeofencePlugin.kt` (Android) and `GeofencePlugin.swift` (iOS) extending Capacitor's `Plugin` class. Register in `CapacitorMainActivity.onCreate()` following the pattern of `SafBridgePlugin`, `WebDavHttpPlugin`, etc.
|
||||
|
||||
### 4.3 Lifecycle Rules
|
||||
|
||||
**Register geofence when:**
|
||||
- Feature is enabled + a task gets `locationReminderId` assigned
|
||||
- App starts with existing location-reminded tasks
|
||||
|
||||
**Unregister geofence when:**
|
||||
- Task is completed, deleted, or `locationReminderId` cleared
|
||||
- SavedLocation is deleted
|
||||
- No more undone tasks reference that location
|
||||
- Feature is disabled
|
||||
|
||||
**iOS 20-region limit:** Only register geofences for the 20 locations with the most active tasks. Re-evaluate when tasks change.
|
||||
|
||||
### 4.4 Effects
|
||||
|
||||
Effects MUST use `inject(LOCAL_ACTIONS)` — geofence registration should never happen during remote sync replay. Each device manages its own geofences based on local state after sync.
|
||||
|
||||
### 4.5 Background Behavior
|
||||
|
||||
When geofence fires while app is in background:
|
||||
- **Android:** `BroadcastReceiver` shows native notification directly (same pattern as `ReminderAlarmReceiver`). Tapping opens app + reminder dialog.
|
||||
- **iOS:** `CLLocationManager` delegate fires local notification. Tapping opens app + reminder dialog.
|
||||
|
||||
### 4.6 Permission Flow
|
||||
|
||||
On first use:
|
||||
1. Check `CapacitorPlatformService.hasCapability('geofencing')`
|
||||
2. Request `ACCESS_FINE_LOCATION` + `ACCESS_BACKGROUND_LOCATION` (Android) or "Always" location access (iOS)
|
||||
3. If denied: feature degrades to display-only, show explanation
|
||||
|
||||
---
|
||||
|
||||
## 5. Reminder Integration
|
||||
|
||||
### 5.1 Integration Point
|
||||
|
||||
The existing `ReminderService.onRemindersActive$` is a derived observable — external code cannot emit to it directly. Two approaches:
|
||||
|
||||
**Option A (recommended):** Add a new public subject on `ReminderService`:
|
||||
|
||||
```typescript
|
||||
// In ReminderService
|
||||
private _onLocationReminders$ = new Subject<TaskWithReminderData[]>();
|
||||
locationReminders$ = this._onLocationReminders$.asObservable();
|
||||
```
|
||||
|
||||
Then merge in `ReminderModule`:
|
||||
|
||||
```typescript
|
||||
merge(
|
||||
this._reminderService.onRemindersActive$,
|
||||
this._reminderService.locationReminders$,
|
||||
).subscribe(reminders => /* existing dialog handling */);
|
||||
```
|
||||
|
||||
**Option B:** Add a public method `emitLocationReminders()` that pushes to the private `_onRemindersActive$` subject.
|
||||
|
||||
### 5.2 Data Shape
|
||||
|
||||
Location-triggered reminders must satisfy `TaskWithReminderData`:
|
||||
|
||||
```typescript
|
||||
interface TaskWithReminderData extends Task {
|
||||
readonly reminderData: { remindAt: number }; // use trigger timestamp
|
||||
readonly parentData?: Task;
|
||||
readonly isDeadlineReminder?: boolean; // false for location
|
||||
}
|
||||
```
|
||||
|
||||
Consider adding `isLocationReminder?: boolean` for UI differentiation.
|
||||
|
||||
### 5.3 Dialog UX Changes
|
||||
|
||||
The existing `DialogViewTaskRemindersComponent` works as-is with minor additions:
|
||||
- Show location name/icon in header (e.g., "At: Grocery Store")
|
||||
- **Snooze** = suppress this location reminder for 1 hour (temporary geofence pause)
|
||||
- **Dismiss** = clear `locationReminderId` on the task
|
||||
- **Done** = mark task complete (same as today)
|
||||
- Hide "Edit Reminder" time picker for location-triggered reminders
|
||||
|
||||
### 5.4 Native Notifications (Background)
|
||||
|
||||
Reuse `CapacitorReminderService` patterns:
|
||||
- Android: native notification via `ReminderNotificationHelper` triggered from geofence `BroadcastReceiver`
|
||||
- iOS: `LocalNotifications.schedule()` triggered from `CLLocationManager` delegate
|
||||
- Both support Done/Snooze action buttons
|
||||
|
||||
---
|
||||
|
||||
## 6. UI Components
|
||||
|
||||
### 6.1 New Components
|
||||
|
||||
| Component | Path | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `saved-location-settings` | `src/app/features/saved-location/saved-location-settings/` | CRUD list in settings page |
|
||||
| `location-picker-dialog` | `src/app/features/saved-location/location-picker-dialog/` | Assign location to task (dropdown + "Use current location") |
|
||||
|
||||
### 6.2 Modified Components
|
||||
|
||||
| Component | Change |
|
||||
|-----------|--------|
|
||||
| Task detail panel | Add "Location" field showing assigned location |
|
||||
| Task schedule dialog | Add location option alongside time-based reminder |
|
||||
| Settings page | Add "Locations" section |
|
||||
| Reminder dialog | Show location name when triggered by geofence |
|
||||
|
||||
### 6.3 Location Picker UX
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Set Location Reminder │
|
||||
├─────────────────────────────────┤
|
||||
│ │
|
||||
│ 📍 Use Current Location │ ← gets GPS, prompts for label
|
||||
│ │
|
||||
│ ─── Saved Locations ────────── │
|
||||
│ │
|
||||
│ 🏠 Home │
|
||||
│ 🏢 Office │
|
||||
│ 🛒 Grocery Store │
|
||||
│ │
|
||||
│ [ Remove Location ] [ Cancel ] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.4 Settings Section
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Location Reminders │
|
||||
├─────────────────────────────────┤
|
||||
│ [Toggle] Enable location │
|
||||
│ reminders │
|
||||
│ │
|
||||
│ Saved Locations: │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ 🏠 Home 200m [✏️][🗑]│ │
|
||||
│ │ 🏢 Office 150m [✏️][🗑]│ │
|
||||
│ │ 🛒 Grocery 200m [✏️][🗑]│ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ [ + Add Location ] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-Platform Behavior
|
||||
|
||||
| Platform | Geofencing | Location Display | Notifications |
|
||||
|----------|-----------|-----------------|---------------|
|
||||
| **Android** | Native `GeofencingClient`, up to 100 fences, background | Yes | Native via `BroadcastReceiver` |
|
||||
| **iOS** | Native `CLLocationManager`, up to 20 regions, background | Yes | Native via `LocalNotifications` |
|
||||
| **Electron** | None | Yes (label only) | None |
|
||||
| **Web** | None | Yes (label only) | None |
|
||||
|
||||
---
|
||||
|
||||
## 8. File Structure
|
||||
|
||||
```
|
||||
src/app/features/saved-location/
|
||||
├── saved-location.model.ts
|
||||
├── saved-location.const.ts # DEFAULT_RADIUS = 200
|
||||
├── saved-location.service.ts
|
||||
├── geofence.service.ts # Capacitor geofencing bridge
|
||||
├── store/
|
||||
│ ├── saved-location.actions.ts
|
||||
│ ├── saved-location.reducer.ts
|
||||
│ └── saved-location.selectors.ts
|
||||
├── saved-location-settings/
|
||||
│ ├── saved-location-settings.component.ts
|
||||
│ └── saved-location-settings.component.html
|
||||
└── location-picker-dialog/
|
||||
├── location-picker-dialog.component.ts
|
||||
└── location-picker-dialog.component.html
|
||||
|
||||
src/app/root-store/meta/task-shared-meta-reducers/
|
||||
└── saved-location-shared.reducer.ts # Cascading delete
|
||||
|
||||
android/app/src/main/java/.../
|
||||
└── GeofencePlugin.kt # Native Android geofencing
|
||||
GeofenceBroadcastReceiver.kt # Handles fence enter events
|
||||
|
||||
ios/App/App/
|
||||
└── GeofencePlugin.swift # Native iOS geofencing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. All Files to Touch
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/app/features/saved-location/saved-location.model.ts` | Entity interface |
|
||||
| `src/app/features/saved-location/saved-location.const.ts` | Defaults |
|
||||
| `src/app/features/saved-location/saved-location.service.ts` | Service |
|
||||
| `src/app/features/saved-location/geofence.service.ts` | Capacitor bridge |
|
||||
| `src/app/features/saved-location/store/saved-location.actions.ts` | Actions |
|
||||
| `src/app/features/saved-location/store/saved-location.reducer.ts` | Reducer + adapter |
|
||||
| `src/app/features/saved-location/store/saved-location.selectors.ts` | Selectors |
|
||||
| `src/app/features/saved-location/saved-location-settings/*` | Settings UI |
|
||||
| `src/app/features/saved-location/location-picker-dialog/*` | Picker dialog |
|
||||
| `src/app/root-store/meta/task-shared-meta-reducers/saved-location-shared.reducer.ts` | Cascading deletes |
|
||||
| `android/.../GeofencePlugin.kt` | Android native geofencing |
|
||||
| `android/.../GeofenceBroadcastReceiver.kt` | Android fence event handler |
|
||||
| `ios/App/App/GeofencePlugin.swift` | iOS native geofencing |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `packages/shared-schema/src/entity-types.ts` | Add `'SAVED_LOCATION'` |
|
||||
| `src/app/op-log/core/action-types.enum.ts` | Add `SAVED_LOCATION_ADD/UPDATE/DELETE` |
|
||||
| `src/app/op-log/core/entity-registry.ts` | Add `SAVED_LOCATION` config |
|
||||
| `src/app/op-log/model/model-config.ts` | Add to `AllModelConfig` + `MODEL_CONFIGS` |
|
||||
| `src/app/root-store/root-state.ts` | Add to `RootState` |
|
||||
| `src/app/root-store/feature-stores.module.ts` | Register feature store |
|
||||
| `src/app/root-store/meta/meta-reducer-registry.ts` | Register cascading delete in Phase 5 |
|
||||
| `src/app/features/tasks/task.model.ts` | Add `locationReminderId` field |
|
||||
| `src/app/features/config/global-config.model.ts` | Add `isLocationRemindersEnabled` |
|
||||
| `src/app/features/config/default-global-config.const.ts` | Set default `false` |
|
||||
| `src/app/core/platform/platform-capabilities.model.ts` | Add `geofencing` capability |
|
||||
| `src/app/features/reminder/reminder.service.ts` | Add `locationReminders$` subject |
|
||||
| `src/app/features/reminder/reminder.module.ts` | Merge location reminders into dialog flow |
|
||||
| `android/app/src/main/AndroidManifest.xml` | Add location permissions |
|
||||
| `android/.../CapacitorMainActivity.kt` | Register `GeofencePlugin` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Future Enhancements (Out of Scope)
|
||||
|
||||
- Map picker with OpenStreetMap tiles
|
||||
- Address search / geocoding (Nominatim)
|
||||
- Leave triggers ("remind when I leave the office")
|
||||
- Time + location combos ("at the store, but only after 5 PM")
|
||||
- Project default locations
|
||||
- Location-based task views ("show tasks for where I am now")
|
||||
- WiFi-based triggers (alternative to GPS, works indoors)
|
||||
- Bluetooth beacon triggers
|
||||
|
||||
---
|
||||
|
||||
## 11. Verification Plan
|
||||
|
||||
1. **Unit tests:** SavedLocation reducer, selectors, service, cascading delete meta-reducer
|
||||
2. **Manual mobile testing:**
|
||||
- Create location from "Use current location"
|
||||
- Assign to a task, verify geofence registration
|
||||
- Move in/out of geofence, verify notification fires
|
||||
- Complete task, verify geofence unregistered
|
||||
- Delete location, verify `locationReminderId` cleared on tasks
|
||||
3. **Desktop/web:** Verify location label displays on tasks, no geofencing attempted
|
||||
4. **Sync:** Create location on device A, verify it appears on device B
|
||||
5. **Lint/format:** `npm run lint`, `npm run prettier`, `npm test`
|
||||
|
|
@ -47,18 +47,9 @@ export const test = base.extend<SuperSyncFixtures>({
|
|||
* Also checks server health and skips test if server unavailable.
|
||||
*/
|
||||
testRunId: async ({}, use, testInfo) => {
|
||||
// Check server health once per worker.
|
||||
// Prefer the result pre-computed in globalSetup (stored as an env var before workers
|
||||
// were forked) to avoid a stampede of concurrent HTTP requests from all workers
|
||||
// simultaneously — which can overload the server and produce false negatives.
|
||||
// Check server health once per worker
|
||||
if (serverHealthyCache === null) {
|
||||
if (process.env.SUPERSYNC_SERVER_HEALTHY !== undefined) {
|
||||
serverHealthyCache = process.env.SUPERSYNC_SERVER_HEALTHY === 'true';
|
||||
} else {
|
||||
// Fallback for when tests are run directly without going through globalSetup
|
||||
// (e.g., `npx playwright test --config ... e2e/tests/sync/foo.spec.ts`)
|
||||
serverHealthyCache = await isServerHealthy();
|
||||
}
|
||||
serverHealthyCache = await isServerHealthy();
|
||||
if (!serverHealthyCache) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
|
|
@ -78,13 +69,9 @@ export const test = base.extend<SuperSyncFixtures>({
|
|||
* Tests are automatically skipped if the server is not healthy.
|
||||
*/
|
||||
serverHealthy: async ({}, use, testInfo) => {
|
||||
// Only check once per worker — use globalSetup env var if available (see testRunId above)
|
||||
// Only check once per worker
|
||||
if (serverHealthyCache === null) {
|
||||
if (process.env.SUPERSYNC_SERVER_HEALTHY !== undefined) {
|
||||
serverHealthyCache = process.env.SUPERSYNC_SERVER_HEALTHY === 'true';
|
||||
} else {
|
||||
serverHealthyCache = await isServerHealthy();
|
||||
}
|
||||
serverHealthyCache = await isServerHealthy();
|
||||
if (!serverHealthyCache) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { FullConfig } from '@playwright/test';
|
|||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { isServerHealthy } from './utils/supersync-helpers';
|
||||
|
||||
/**
|
||||
* Warm up the dev server by fetching the app once before any tests start.
|
||||
|
|
@ -70,20 +69,6 @@ const globalSetup = async (config: FullConfig): Promise<void> => {
|
|||
config.projects[0]?.use?.baseURL ||
|
||||
'http://localhost:4242';
|
||||
await warmUpDevServer(baseURL);
|
||||
|
||||
// Check SuperSync server health ONCE here, before workers start.
|
||||
// Without this, each worker checks independently on startup — with many workers
|
||||
// running simultaneously, the concurrent health-check requests can overload the
|
||||
// supersync server and cause false negatives, making workers skip all their tests.
|
||||
// By storing the result in an env var set before workers are forked, every worker
|
||||
// reads the cached result instantly instead of making HTTP requests.
|
||||
const healthy = await isServerHealthy().catch(() => false);
|
||||
process.env.SUPERSYNC_SERVER_HEALTHY = healthy ? 'true' : 'false';
|
||||
if (healthy) {
|
||||
console.log('SuperSync server healthy — supersync tests will run');
|
||||
} else {
|
||||
console.log('SuperSync server not available — supersync tests will be skipped');
|
||||
}
|
||||
};
|
||||
|
||||
export default globalSetup;
|
||||
|
|
|
|||
|
|
@ -191,14 +191,8 @@ export class SuperSyncPage extends BasePage {
|
|||
// piggybacked ops in the response, causing data to sync between clients
|
||||
// outside of explicit syncAndWait(). Set here (not in createSimulatedClient)
|
||||
// so tests using enableWebSocket:true still get immediate uploads.
|
||||
// 3. Block WsTriggeredDownloadService: Even when routeWebSocket() closes the
|
||||
// connection, a WS notification can slip through the moment the connection
|
||||
// opens (before it's closed). Setting __SP_E2E_BLOCK_WS_DOWNLOAD ensures
|
||||
// WsTriggeredDownloadService ignores any such notifications, preventing
|
||||
// uncontrolled background syncs that race with explicit syncAndWait() calls.
|
||||
await this.page.evaluate(() => {
|
||||
(globalThis as any).__SP_E2E_BLOCK_IMMEDIATE_UPLOAD = true;
|
||||
(globalThis as any).__SP_E2E_BLOCK_WS_DOWNLOAD = true;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
renameTask,
|
||||
archiveDoneTasks,
|
||||
expectTaskInWorklog,
|
||||
hasTaskInWorklog,
|
||||
navigateToWorkView,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
|
@ -268,30 +267,10 @@ test.describe('@supersync Archive Conflict Resolution', () => {
|
|||
console.log('[BugB] Client B: task not visible in active list');
|
||||
|
||||
// ============ PHASE 7: Verify task IN worklog ============
|
||||
// After archive-wins conflict + sync rounds, the archived task may appear under
|
||||
// either the original title (if archive applied first) or the renamed title (if
|
||||
// the rename op was also synced). Accept either to avoid brittleness.
|
||||
const clientAFound =
|
||||
(await hasTaskInWorklog(clientA, taskName)) ||
|
||||
(await hasTaskInWorklog(clientA, taskRenamed));
|
||||
if (!clientAFound) {
|
||||
throw new Error(
|
||||
`[BugB] Client A: Expected task to be in worklog under "${taskName}" or "${taskRenamed}"`,
|
||||
);
|
||||
}
|
||||
await expectTaskInWorklog(clientA, taskName);
|
||||
console.log('[BugB] Client A: task found in worklog');
|
||||
|
||||
// Client B applied the rename locally before archive was received, so the task
|
||||
// is archived under the renamed title. But also accept the original in case the
|
||||
// archive was applied before the rename reached Client B.
|
||||
const clientBFound =
|
||||
(await hasTaskInWorklog(clientB, taskRenamed)) ||
|
||||
(await hasTaskInWorklog(clientB, taskName));
|
||||
if (!clientBFound) {
|
||||
throw new Error(
|
||||
`[BugB] Client B: Expected task to be in worklog under "${taskRenamed}" or "${taskName}"`,
|
||||
);
|
||||
}
|
||||
await expectTaskInWorklog(clientB, taskName);
|
||||
console.log('[BugB] Client B: task found in worklog');
|
||||
|
||||
console.log('[BugB] ✓ Test B passed: LWW Update did not resurrect archived task');
|
||||
|
|
|
|||
|
|
@ -672,11 +672,8 @@ test.describe('@supersync SuperSync E2E', () => {
|
|||
await saveAndGoHomeBtn.click();
|
||||
console.log('[Archive Test] Client B clicked Save and go home (archiving)');
|
||||
|
||||
// Wait for navigation back to work view.
|
||||
// Use negative lookahead to avoid matching /tag/TODAY/daily-summary prematurely.
|
||||
await clientB.page.waitForURL(/(tag\/TODAY(?!\/daily-summary))/, {
|
||||
timeout: 10000,
|
||||
});
|
||||
// Wait for navigation back to work view
|
||||
await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 });
|
||||
await clientB.page.waitForLoadState('networkidle');
|
||||
console.log('[Archive Test] Client B back on work view after archiving');
|
||||
|
||||
|
|
|
|||
|
|
@ -239,34 +239,8 @@ export const createSimulatedClient = async (
|
|||
}
|
||||
});
|
||||
|
||||
// Navigate to app with retry for transient ERR_CONNECTION_REFUSED.
|
||||
// Under parallel load (many workers × 2-3 browser contexts each), the Angular
|
||||
// dev server can temporarily refuse connections. Retrying recovers from this
|
||||
// without failing the test outright.
|
||||
let lastGotoError: Error | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await page.goto('/');
|
||||
lastGotoError = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
lastGotoError = e as Error;
|
||||
const isConnectionRefused = lastGotoError.message.includes(
|
||||
'ERR_CONNECTION_REFUSED',
|
||||
);
|
||||
if (attempt < 2 && isConnectionRefused) {
|
||||
const delay = 1000 * (attempt + 1);
|
||||
console.log(
|
||||
`[Client ${clientName}] page.goto('/') failed (attempt ${attempt + 1}/3): ERR_CONNECTION_REFUSED — retrying in ${delay}ms`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
} else {
|
||||
break; // Non-connection error or last attempt — let it throw below
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastGotoError) throw lastGotoError;
|
||||
|
||||
// Navigate to app and wait for ready
|
||||
await page.goto('/');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const workView = new WorkViewPage(page, `${clientName}-${testPrefix}`);
|
||||
|
|
@ -527,10 +501,6 @@ export const markTaskDone = async (
|
|||
const task = getTaskElement(client, taskName);
|
||||
await task.hover();
|
||||
await task.locator('done-toggle').click();
|
||||
// Wait for the 200ms animation delay in toggleDoneWithAnimation to complete.
|
||||
// During CDK drag animation the task may temporarily resolve to 2 elements;
|
||||
// use .first() to avoid strict-mode violations.
|
||||
await expect(task.first()).toHaveClass(/isDone/, { timeout: UI_VISIBLE_TIMEOUT });
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -547,8 +517,6 @@ export const markSubtaskDone = async (
|
|||
const subtask = getSubtaskElement(client, subtaskName);
|
||||
await subtask.hover();
|
||||
await subtask.locator('done-toggle').click();
|
||||
// Wait for the 200ms animation delay in toggleDoneWithAnimation to complete
|
||||
await expect(subtask).toHaveClass(/isDone/, { timeout: UI_VISIBLE_TIMEOUT });
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import { join } from 'path';
|
||||
import { readdir, unlink } from 'fs/promises';
|
||||
import { log, warn } from 'electron-log/main';
|
||||
|
||||
/**
|
||||
* Deletes stale LevelDB LOCK files left in the IndexedDB directory.
|
||||
*
|
||||
* When Electron (Chromium) exits uncleanly (e.g., session logout with autostart),
|
||||
* the LevelDB LOCK files inside the IndexedDB backing stores are sometimes not
|
||||
* cleaned up. On the next launch these orphaned files block IndexedDB from opening,
|
||||
* producing the "Internal error opening backing store for indexedDB.open" error.
|
||||
*
|
||||
* This is safe to call because by the time it runs, `requestSingleInstanceLock()`
|
||||
* has already ensured we are the only running instance, so no legitimate process
|
||||
* can hold those locks.
|
||||
*
|
||||
* Only runs on Linux, where this startup race condition has been observed.
|
||||
*
|
||||
* @see https://github.com/electron/electron/issues/18263
|
||||
* @see https://github.com/super-productivity/super-productivity/issues/7191
|
||||
*/
|
||||
export const clearStaleLevelDbLocks = async (userDataPath: string): Promise<void> => {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
const idbDir = join(userDataPath, 'IndexedDB');
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(idbDir);
|
||||
} catch (e: unknown) {
|
||||
// Directory doesn't exist yet (fresh install) — nothing to clean up.
|
||||
// Any other error (e.g., EACCES, EPERM) is unexpected and worth logging.
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
warn(`[clearStaleLevelDbLocks] Could not read IndexedDB directory:`, e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const leveldbDirs = entries.filter((e) => e.endsWith('.leveldb'));
|
||||
|
||||
await Promise.all(
|
||||
leveldbDirs.map(async (dir) => {
|
||||
const lockPath = join(idbDir, dir, 'LOCK');
|
||||
try {
|
||||
await unlink(lockPath);
|
||||
log(`[clearStaleLevelDbLocks] Removed stale lock: ${lockPath}`);
|
||||
} catch (e: unknown) {
|
||||
// LOCK file doesn't exist or is already released — this is the normal case
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
warn(`[clearStaleLevelDbLocks] Could not remove ${lockPath}:`, e);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
2
electron/electronAPI.d.ts
vendored
2
electron/electronAPI.d.ts
vendored
|
|
@ -48,7 +48,7 @@ export interface ElectronAPI {
|
|||
|
||||
fileSyncRemove(args: { filePath: string }): Promise<unknown | Error>;
|
||||
|
||||
fileSyncListFiles(args: { dirPath: string }): Promise<string[] | Error>;
|
||||
fileSyncListFiles(args: { dirPath: string }): Promise<string[] | Error>; // NEW
|
||||
|
||||
checkDirExists(args: { dirPath: string }): Promise<true | Error>;
|
||||
|
||||
|
|
|
|||
|
|
@ -112,36 +112,6 @@ export const initLocalFileSyncAdapter = (): void => {
|
|||
return true;
|
||||
} catch (e) {
|
||||
log('ERR: error while checking dir ' + dirPath);
|
||||
if ((e as NodeJS.ErrnoException).code === 'EACCES') {
|
||||
log(
|
||||
'ERR: Permission denied. If running as a snap, ensure the "home" or "removable-media" interface is connected.',
|
||||
);
|
||||
}
|
||||
error(e);
|
||||
return e instanceof Error ? e : new Error(String(e));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.FILE_SYNC_LIST_FILES,
|
||||
(
|
||||
ev,
|
||||
{
|
||||
dirPath,
|
||||
}: {
|
||||
dirPath: string;
|
||||
},
|
||||
): string[] | Error => {
|
||||
try {
|
||||
return readdirSync(dirPath);
|
||||
} catch (e) {
|
||||
log('ERR: Sync error while listing files in ' + dirPath);
|
||||
if ((e as NodeJS.ErrnoException).code === 'EACCES') {
|
||||
log(
|
||||
'ERR: Permission denied. If running as a snap, ensure the "home" or "removable-media" interface is connected.',
|
||||
);
|
||||
}
|
||||
error(e);
|
||||
return e instanceof Error ? e : new Error(String(e));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ const appCloseHandler = (app: App): void => {
|
|||
|
||||
mainWin.on('close', (event) => {
|
||||
// NOTE: this might not work if we run a second instance of the app
|
||||
log('close event: isQuiting=', getIsQuiting(), 'pendingBeforeCloseIds=', ids);
|
||||
log('close, isQuiting:', getIsQuiting());
|
||||
if (!getIsQuiting()) {
|
||||
event.preventDefault();
|
||||
if (getIsMinimizeToTray()) {
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ const ea: ElectronAPI = {
|
|||
dataStr: string | undefined;
|
||||
}>,
|
||||
fileSyncRemove: (filePath) => _invoke('FILE_SYNC_REMOVE', filePath) as Promise<void>,
|
||||
fileSyncListFiles: (args) =>
|
||||
_invoke('FILE_SYNC_LIST_FILES', args) as Promise<string[] | Error>,
|
||||
fileSyncListFiles: ({ dirPath }) =>
|
||||
_invoke('FILE_SYNC_LIST_FILES', dirPath) as Promise<string[] | Error>,
|
||||
checkDirExists: (dirPath) =>
|
||||
_invoke('CHECK_DIR_EXISTS', dirPath) as Promise<true | Error>,
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export enum IPC {
|
|||
FILE_SYNC_LOAD = 'FILE_SYNC_LOAD',
|
||||
FILE_SYNC_SAVE = 'FILE_SYNC_SAVE',
|
||||
FILE_SYNC_REMOVE = 'FILE_SYNC_REMOVE',
|
||||
FILE_SYNC_LIST_FILES = 'FILE_SYNC_LIST_FILES',
|
||||
FILE_SYNC_LIST_FILES = 'FILE_SYNC_LIST_FILES', // NEW
|
||||
FILE_SYNC_GET_REV_AND_CLIENT_UPDATE = 'FILE_SYNC_GET_REV_AND_CLIENT_UPDATE',
|
||||
|
||||
CHECK_DIR_EXISTS = 'CHECK_DIR_EXISTS',
|
||||
|
|
|
|||
|
|
@ -21,15 +21,14 @@ import { CONFIG } from './CONFIG';
|
|||
import { lazySetInterval } from './shared-with-frontend/lazy-set-interval';
|
||||
import { initIndicator } from './indicator';
|
||||
import { quitApp, showOrFocus } from './various-shared';
|
||||
import { createWindow, getWin } from './main-window';
|
||||
import { createWindow } from './main-window';
|
||||
import { IdleTimeHandler } from './idle-time-handler';
|
||||
import { destroyTaskWidget } from './task-widget/task-widget';
|
||||
import {
|
||||
initializeProtocolHandling,
|
||||
processPendingProtocolUrls,
|
||||
} from './protocol-handler';
|
||||
import { getIsQuiting, setIsQuiting, setIsLocked } from './shared-state';
|
||||
import { clearStaleLevelDbLocks } from './clear-stale-idb-locks';
|
||||
import { getIsQuiting, setIsLocked } from './shared-state';
|
||||
|
||||
const ICONS_FOLDER = __dirname + '/assets/icons/';
|
||||
const IS_MAC = process.platform === 'darwin';
|
||||
|
|
@ -300,32 +299,18 @@ export const startApp = (): void => {
|
|||
appIN.on('will-quit', () => {
|
||||
// un-register all shortcuts.
|
||||
globalShortcut.unregisterAll();
|
||||
// Safe to remove IPC listeners here: all windows are closed and before-close
|
||||
// IPC flows (sync, finish-day) are guaranteed to have completed.
|
||||
ipcMain.removeAllListeners();
|
||||
});
|
||||
|
||||
appIN.on('before-quit', (event) => {
|
||||
log('App before-quit: isQuiting=', getIsQuiting());
|
||||
if (!getIsQuiting()) {
|
||||
// Native quit path (Cmd+Q, Dock > Quit on macOS): app.quit() was called
|
||||
// by the OS without going through quitApp(), so isQuiting was never set.
|
||||
// Prevent the immediate quit and delegate to the window close handler,
|
||||
// which manages the before-close callback flow (sync, finish-day, etc.)
|
||||
// and sets isQuiting=true before re-quitting.
|
||||
event.preventDefault();
|
||||
const win = getWin();
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
} else {
|
||||
// No window to close — set flag and re-trigger quit directly.
|
||||
setIsQuiting(true);
|
||||
app.quit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// isQuiting=true: all before-close IPC work is complete — safe to clean up.
|
||||
appIN.on('before-quit', () => {
|
||||
log('App before-quit: cleaning up resources');
|
||||
|
||||
// Clean up task widget before quitting
|
||||
destroyTaskWidget();
|
||||
|
||||
// Remove all IPC listeners to prevent memory leaks
|
||||
ipcMain.removeAllListeners();
|
||||
|
||||
// Clear any pending timeouts/intervals
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
|
|
@ -383,10 +368,6 @@ export const startApp = (): void => {
|
|||
|
||||
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
||||
async function createMainWin(): Promise<void> {
|
||||
// Remove stale LevelDB LOCK files before the renderer opens IndexedDB.
|
||||
// Orphaned locks from unclean session shutdowns block the backing store open.
|
||||
await clearStaleLevelDbLocks(app.getPath('userData'));
|
||||
|
||||
mainWin = await createWindow({
|
||||
app,
|
||||
IS_DEV,
|
||||
|
|
|
|||
2175
package-lock.json
generated
2175
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -230,7 +230,7 @@
|
|||
"cross-env": "^7.0.3",
|
||||
"detect-it": "^4.0.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"electron": "41.2.0",
|
||||
"electron": "37.10.3",
|
||||
"electron-builder": "^26.7.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
|
@ -282,7 +282,7 @@
|
|||
},
|
||||
"overrides": {
|
||||
"app-builder-lib": {
|
||||
"minimatch": "10.2.5"
|
||||
"minimatch": "10.1.1"
|
||||
}
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.3.2"
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"CFG": {
|
||||
"REPO": "Repository (owner/repo)",
|
||||
"TOKEN": "Personal Access Token (required for private repos/optional for public repos)",
|
||||
"TOKEN": "Personal Access Token (optional, for private repos)",
|
||||
"FILTER_USERNAME": "Filter username (ignore own comments for update detection)",
|
||||
"BACKLOG_QUERY": "Backlog query (default: \"sort:updated state:open assignee:@me\")",
|
||||
"HOW_TO_GET_TOKEN": "How to get a token"
|
||||
|
|
|
|||
2
packages/plugin-dev/package-lock.json
generated
2
packages/plugin-dev/package-lock.json
generated
|
|
@ -34,7 +34,7 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.3.2"
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"build": "tsc",
|
||||
"start": "node dist/src/index.js",
|
||||
"dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts",
|
||||
"pretest": "npm run build --workspace=@sp/shared-schema && prisma generate",
|
||||
"pretest": "prisma generate",
|
||||
"test": "vitest run",
|
||||
"docker:build": "./scripts/build-and-push.sh",
|
||||
"docker:deploy": "./scripts/deploy.sh",
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
"dotenv": "^17.2.3",
|
||||
"fastify": "^5.8.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^8.0.5",
|
||||
"nodemailer": "^8.0.4",
|
||||
"uuidv7": "^1.1.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,6 +13,6 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.3.2"
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<nav
|
||||
class="mobile-bottom-nav"
|
||||
[class.entrance]="isEntrance()"
|
||||
(animationend)="onEntranceAnimationEnd($event)"
|
||||
>
|
||||
<button
|
||||
mat-button
|
||||
|
|
@ -26,7 +25,7 @@
|
|||
mat-fab
|
||||
class="add-task-button"
|
||||
[class.smaller]="hasProjectBacklog()"
|
||||
[class.hide-for-native]="isEntranceAnimating()"
|
||||
[class.hide-for-native]="isAndroid && isEntrance()"
|
||||
color="primary"
|
||||
(click)="showAddTaskBar()"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@
|
|||
height: 28px;
|
||||
}
|
||||
|
||||
// On Android, hide during the slide-up entrance animation.
|
||||
// On Android, hide during entrance animation (native add-task button is visible).
|
||||
// var(--transition-standard) on .add-task-button already transitions opacity,
|
||||
// so removing the class fades in automatically.
|
||||
&.hide-for-native {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core';
|
||||
import { NavigationEnd, Router, RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
|
@ -56,35 +48,6 @@ export class MobileBottomNavComponent {
|
|||
isEntrance = input(false);
|
||||
readonly isAndroid = IS_ANDROID_NATIVE;
|
||||
|
||||
// Tracks whether the slide-up entrance animation is actively running.
|
||||
// On Android, the web FAB is hidden only during this animation (not the full
|
||||
// entrance duration) so that the button appears as soon as the nav settles —
|
||||
// even if the native startup overlay was not shown (e.g. process-death recreation).
|
||||
readonly isEntranceAnimating = signal(false);
|
||||
private _entranceAnimationTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
if (this.isEntrance() && this.isAndroid) {
|
||||
this.isEntranceAnimating.set(true);
|
||||
// Safety fallback: clear if animationend doesn't fire
|
||||
// (e.g. prefers-reduced-motion: reduce → animation: none).
|
||||
// Timeout = 250ms delay + 400ms duration + 50ms buffer = 700ms
|
||||
this._entranceAnimationTimeout = setTimeout(() => {
|
||||
this.isEntranceAnimating.set(false);
|
||||
}, 700);
|
||||
onCleanup(() => clearTimeout(this._entranceAnimationTimeout));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onEntranceAnimationEnd(event: AnimationEvent): void {
|
||||
if (event.animationName === 'slide-up-from-bottom') {
|
||||
this.isEntranceAnimating.set(false);
|
||||
clearTimeout(this._entranceAnimationTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
readonly T = T;
|
||||
readonly TODAY_TAG = TODAY_TAG;
|
||||
readonly todayTagId = TODAY_TAG.id;
|
||||
|
|
|
|||
|
|
@ -469,9 +469,6 @@ export class GlobalThemeService {
|
|||
* Adds/removes CSS classes when keyboard shows/hides.
|
||||
*/
|
||||
private _initIOSKeyboardHandling(): void {
|
||||
// Show the native iOS accessory bar ("Done" button) above the keyboard
|
||||
Keyboard.setAccessoryBarVisible({ isVisible: true });
|
||||
|
||||
Keyboard.addListener('keyboardWillShow', (info: KeyboardInfo) => {
|
||||
Log.log('iOS keyboard will show', info);
|
||||
this.document.body.classList.add(BodyClass.isKeyboardVisible);
|
||||
|
|
|
|||
165
src/app/core/util/client-id.service.spec.ts
Normal file
165
src/app/core/util/client-id.service.spec.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { ClientIdService } from './client-id.service';
|
||||
import { SnackService } from '../snack/snack.service';
|
||||
import { openDB } from 'idb';
|
||||
|
||||
// Constants that mirror the private constants in ClientIdService
|
||||
const DB_NAME = 'pf';
|
||||
const DB_STORE_NAME = 'main';
|
||||
const DB_VERSION = 1;
|
||||
const CLIENT_ID_KEY = '__client_id_';
|
||||
|
||||
/**
|
||||
* Helper to write a raw value directly to the 'pf' IndexedDB store,
|
||||
* bypassing ClientIdService validation so we can test recovery paths.
|
||||
*/
|
||||
const writeRawClientId = async (value: unknown): Promise<void> => {
|
||||
const db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade: (database) => {
|
||||
if (!database.objectStoreNames.contains(DB_STORE_NAME)) {
|
||||
database.createObjectStore(DB_STORE_NAME);
|
||||
}
|
||||
},
|
||||
});
|
||||
await db.put(DB_STORE_NAME, value, CLIENT_ID_KEY);
|
||||
db.close();
|
||||
};
|
||||
|
||||
describe('ClientIdService', () => {
|
||||
let service: ClientIdService;
|
||||
let mockSnackService: jasmine.SpyObj<SnackService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ClientIdService, { provide: SnackService, useValue: mockSnackService }],
|
||||
});
|
||||
service = TestBed.inject(ClientIdService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up IndexedDB between tests
|
||||
const db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade: (database) => {
|
||||
if (!database.objectStoreNames.contains(DB_STORE_NAME)) {
|
||||
database.createObjectStore(DB_STORE_NAME);
|
||||
}
|
||||
},
|
||||
});
|
||||
await db.delete(DB_STORE_NAME, CLIENT_ID_KEY);
|
||||
db.close();
|
||||
service.clearCache();
|
||||
});
|
||||
|
||||
describe('loadClientId()', () => {
|
||||
it('should return null when no clientId is stored', async () => {
|
||||
const result = await service.loadClientId();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return a valid new-format clientId (e.g. "B_H8AR")', async () => {
|
||||
await writeRawClientId('B_H8AR');
|
||||
const result = await service.loadClientId();
|
||||
expect(result).toBe('B_H8AR');
|
||||
});
|
||||
|
||||
it('should return a valid old-format clientId (10+ chars)', async () => {
|
||||
const oldFormatId = 'LongClientId123';
|
||||
await writeRawClientId(oldFormatId);
|
||||
const result = await service.loadClientId();
|
||||
expect(result).toBe(oldFormatId);
|
||||
});
|
||||
|
||||
it('should return null (not throw) for an invalid clientId format, enabling recovery (#6197)', async () => {
|
||||
// Simulate a corrupted or truncated clientId that doesn't match either format
|
||||
await writeRawClientId('BAD');
|
||||
// Must NOT throw - returning null allows caller to generate a fresh clientId
|
||||
const result = await service.loadClientId();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should warn the user when clientId is invalid and will be regenerated', async () => {
|
||||
await writeRawClientId('BAD');
|
||||
await service.loadClientId();
|
||||
expect(mockSnackService.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ type: 'WARNING' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null (not throw) for empty string clientId', async () => {
|
||||
await writeRawClientId('');
|
||||
const result = await service.loadClientId();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should cache a valid clientId after first load', async () => {
|
||||
await writeRawClientId('B_H8AR');
|
||||
const first = await service.loadClientId();
|
||||
// Delete from DB to confirm cache is used on second call
|
||||
const db = await openDB(DB_NAME, DB_VERSION);
|
||||
await db.delete(DB_STORE_NAME, CLIENT_ID_KEY);
|
||||
db.close();
|
||||
const second = await service.loadClientId();
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateNewClientId()', () => {
|
||||
it('should generate a clientId matching the new format', async () => {
|
||||
const id = await service.generateNewClientId();
|
||||
expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(id)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should persist the generated clientId so loadClientId() returns it', async () => {
|
||||
const generated = await service.generateNewClientId();
|
||||
service.clearCache();
|
||||
const loaded = await service.loadClientId();
|
||||
expect(loaded).toBe(generated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrGenerateClientId()', () => {
|
||||
it('should return existing valid clientId', async () => {
|
||||
await writeRawClientId('B_H8AR');
|
||||
const result = await service.getOrGenerateClientId();
|
||||
expect(result).toBe('B_H8AR');
|
||||
});
|
||||
|
||||
it('should generate and persist a new clientId when none is stored', async () => {
|
||||
const result = await service.getOrGenerateClientId();
|
||||
expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(result)).toBeTrue();
|
||||
// Verify it was persisted: clear cache and reload
|
||||
service.clearCache();
|
||||
const loaded = await service.loadClientId();
|
||||
expect(loaded).toBe(result);
|
||||
});
|
||||
|
||||
it('should generate a new clientId when stored value is invalid', async () => {
|
||||
await writeRawClientId('BAD');
|
||||
const result = await service.getOrGenerateClientId();
|
||||
expect(/^[BEAI]_[a-zA-Z0-9]{4}$/.test(result)).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistClientId()', () => {
|
||||
it('should persist a valid new-format clientId', async () => {
|
||||
await service.persistClientId('E_abcd');
|
||||
service.clearCache();
|
||||
const loaded = await service.loadClientId();
|
||||
expect(loaded).toBe('E_abcd');
|
||||
});
|
||||
|
||||
it('should persist a valid old-format clientId', async () => {
|
||||
const oldId = 'OldFormatClientId1';
|
||||
await service.persistClientId(oldId);
|
||||
service.clearCache();
|
||||
const loaded = await service.loadClientId();
|
||||
expect(loaded).toBe(oldId);
|
||||
});
|
||||
|
||||
it('should throw for invalid format without persisting', async () => {
|
||||
await expectAsync(service.persistClientId('INVALID')).toBeRejected();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { openDB, IDBPDatabase } from 'idb';
|
||||
import { OpLog } from '../log';
|
||||
import { SnackService } from '../snack/snack.service';
|
||||
import { T } from '../../t.const';
|
||||
|
||||
// Database constants - must match PFAPI's storage
|
||||
const DB_NAME = 'pf';
|
||||
|
|
@ -21,6 +23,7 @@ const CLIENT_ID_KEY = '__client_id_';
|
|||
providedIn: 'root',
|
||||
})
|
||||
export class ClientIdService {
|
||||
private _snackService = inject(SnackService);
|
||||
private _db: IDBPDatabase | null = null;
|
||||
private _cachedClientId: string | null = null;
|
||||
|
||||
|
|
@ -44,16 +47,22 @@ export class ClientIdService {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Validate clientId format
|
||||
const isOldFormat = clientId.length >= 10;
|
||||
const isNewFormat = /^[BEAI]_[a-zA-Z0-9]{4}$/.test(clientId);
|
||||
|
||||
if (!isOldFormat && !isNewFormat) {
|
||||
OpLog.critical('ClientIdService.loadClientId() Invalid clientId loaded:', {
|
||||
clientId,
|
||||
length: clientId.length,
|
||||
if (!this._isValidClientIdFormat(clientId)) {
|
||||
// Unrecognized format — log but treat as missing rather than throwing.
|
||||
// Throwing here permanently blocks sync (issue #6197: "Invalid clientId loaded: B_H8AR").
|
||||
// Returning null causes the caller to generate a fresh clientId, which unblocks sync.
|
||||
OpLog.critical(
|
||||
'ClientIdService.loadClientId() Invalid clientId format, will regenerate:',
|
||||
{
|
||||
clientId,
|
||||
length: clientId.length,
|
||||
},
|
||||
);
|
||||
this._snackService.open({
|
||||
msg: T.F.SYNC.S.WARN_CLIENT_ID_REGENERATED,
|
||||
type: 'WARNING',
|
||||
});
|
||||
throw new Error(`Invalid clientId loaded: ${clientId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this._cachedClientId = clientId;
|
||||
|
|
@ -84,13 +93,11 @@ export class ClientIdService {
|
|||
* Persists an existing client ID (e.g., from legacy migration).
|
||||
*
|
||||
* Validates the format before writing to prevent invalid IDs from
|
||||
* being stored. loadClientId() validates on read, so an invalid
|
||||
* persisted ID would throw on next load.
|
||||
* being stored. loadClientId() treats an invalid stored ID as missing
|
||||
* and returns null, causing the caller to regenerate a fresh clientId.
|
||||
*/
|
||||
async persistClientId(clientId: string): Promise<void> {
|
||||
const isOldFormat = clientId.length >= 10;
|
||||
const isNewFormat = /^[BEAI]_[a-zA-Z0-9]{4}$/.test(clientId);
|
||||
if (!isOldFormat && !isNewFormat) {
|
||||
if (!this._isValidClientIdFormat(clientId)) {
|
||||
throw new Error(`Cannot persist invalid clientId: ${clientId}`);
|
||||
}
|
||||
const db = await this._getDb();
|
||||
|
|
@ -99,6 +106,16 @@ export class ClientIdService {
|
|||
OpLog.normal('ClientIdService.persistClientId() persisted:', { clientId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the existing client ID, or generates and persists a new one if
|
||||
* none is stored or the stored value is invalid.
|
||||
*
|
||||
* This is the preferred entry point for callers that always need a valid ID.
|
||||
*/
|
||||
async getOrGenerateClientId(): Promise<string> {
|
||||
return (await this.loadClientId()) ?? (await this.generateNewClientId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached client ID.
|
||||
*
|
||||
|
|
@ -108,6 +125,15 @@ export class ClientIdService {
|
|||
this._cachedClientId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the clientId matches a known valid format.
|
||||
* Old format: any string of length >= 10 (legacy IDs).
|
||||
* New format: {platform}_{4-char-base62} e.g. "B_a7Kx".
|
||||
*/
|
||||
private _isValidClientIdFormat(clientId: string): boolean {
|
||||
return clientId.length >= 10 || /^[BEAI]_[a-zA-Z0-9]{4}$/.test(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or opens the IndexedDB database.
|
||||
*/
|
||||
|
|
@ -169,13 +195,12 @@ export class ClientIdService {
|
|||
|
||||
/**
|
||||
* Generates a random base62 string of the specified length.
|
||||
* Uses crypto.getRandomValues() for non-predictable randomness.
|
||||
*/
|
||||
private _generateBase62(length: number): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => chars[b % chars.length]).join('');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,18 +207,6 @@
|
|||
<mat-icon>done_all</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Reset cycles button (In Progress state, Pomodoro only) -->
|
||||
@if (isShowCompleteSessionButton() && isPomodoro()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="T.F.FOCUS_MODE.RESET_CYCLES | translate"
|
||||
(click)="resetCycles()"
|
||||
class="reset-cycles-btn"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import {
|
|||
completeTask,
|
||||
focusModeLoaded,
|
||||
pauseFocusSession,
|
||||
resetCycles,
|
||||
selectFocusTask,
|
||||
setFocusModeMode,
|
||||
setFocusSessionDuration,
|
||||
|
|
@ -182,7 +181,6 @@ export class FocusModeMainComponent {
|
|||
isShowTimeAdjustButtons = computed(
|
||||
() => this._isInProgress() && this.mode() !== FocusModeMode.Flowtime,
|
||||
);
|
||||
isPomodoro = computed(() => this.mode() === FocusModeMode.Pomodoro);
|
||||
|
||||
// Play button should be disabled when sync with tracking is enabled but no task is selected
|
||||
isPlayButtonDisabled = computed(() => {
|
||||
|
|
@ -404,10 +402,6 @@ export class FocusModeMainComponent {
|
|||
this._store.dispatch(unPauseFocusSession());
|
||||
}
|
||||
|
||||
resetCycles(): void {
|
||||
this._store.dispatch(resetCycles());
|
||||
}
|
||||
|
||||
selectMode(mode: FocusModeMode | string | number): void {
|
||||
if (!Object.values(FocusModeMode).includes(mode as FocusModeMode)) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -56,8 +56,7 @@
|
|||
.title {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
@include truncateText();
|
||||
font-size: var(--planner-font-size-mobile);
|
||||
|
||||
@include mq(xs) {
|
||||
|
|
|
|||
|
|
@ -40,8 +40,7 @@
|
|||
font-size: var(--planner-font-size-mobile);
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
@include truncateText();
|
||||
|
||||
@include mq(xs) {
|
||||
font-size: var(--planner-font-size);
|
||||
|
|
|
|||
|
|
@ -40,8 +40,7 @@
|
|||
font-size: var(--planner-font-size-mobile);
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
@include truncateText();
|
||||
|
||||
@include mq(xs) {
|
||||
font-size: var(--planner-font-size);
|
||||
|
|
|
|||
|
|
@ -105,8 +105,7 @@ done-toggle {
|
|||
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
@include truncateText();
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
|
|
@ -120,8 +119,7 @@ done-toggle {
|
|||
padding-right: var(--s-half);
|
||||
padding-top: var(--s-half);
|
||||
padding-bottom: var(--s-half);
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
@include truncateText();
|
||||
line-height: 1.1;
|
||||
font-size: var(--planner-font-size-smaller-mobile);
|
||||
|
||||
|
|
|
|||
|
|
@ -281,9 +281,9 @@ describe('AddTaskBarParserService', () => {
|
|||
});
|
||||
|
||||
describe('Parsing Integration', () => {
|
||||
it('should call updateEstimate when text contains estimate syntax', async () => {
|
||||
it('should call updateEstimate when parsing text', async () => {
|
||||
await service.parseAndUpdateText(
|
||||
'Task with estimate 30m',
|
||||
'Task with potential time estimate',
|
||||
mockConfig,
|
||||
mockProjects,
|
||||
mockTags,
|
||||
|
|
@ -293,7 +293,7 @@ describe('AddTaskBarParserService', () => {
|
|||
expect(mockStateService.updateEstimate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call updateEstimate for text without estimate on first parse', async () => {
|
||||
it('should handle null time estimates', async () => {
|
||||
await service.parseAndUpdateText(
|
||||
'Simple task',
|
||||
mockConfig,
|
||||
|
|
@ -302,24 +302,7 @@ describe('AddTaskBarParserService', () => {
|
|||
mockDefaultProject,
|
||||
);
|
||||
|
||||
expect(mockStateService.updateEstimate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call updateEstimate when typing text without estimate syntax for the first time', async () => {
|
||||
// Simulates: user sets estimate via dropdown, then types task title.
|
||||
// The parser has no previous result (first parse after empty input).
|
||||
// Since the parsed estimate is null and there's no previous result to
|
||||
// diff against, updateEstimate should NOT be called to avoid wiping
|
||||
// out the dropdown-set value.
|
||||
await service.parseAndUpdateText(
|
||||
'My new task',
|
||||
mockConfig,
|
||||
mockProjects,
|
||||
mockTags,
|
||||
mockDefaultProject,
|
||||
);
|
||||
|
||||
expect(mockStateService.updateEstimate).not.toHaveBeenCalled();
|
||||
expect(mockStateService.updateEstimate).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should call updateSpent when parsing text', async () => {
|
||||
|
|
|
|||
|
|
@ -188,9 +188,8 @@ export class AddTaskBarParserService {
|
|||
}
|
||||
|
||||
if (
|
||||
(!this._previousParseResult && currentResult.timeEstimate !== null) ||
|
||||
(this._previousParseResult &&
|
||||
this._previousParseResult.timeEstimate !== currentResult.timeEstimate)
|
||||
!this._previousParseResult ||
|
||||
this._previousParseResult.timeEstimate !== currentResult.timeEstimate
|
||||
) {
|
||||
this._stateService.updateEstimate(currentResult.timeEstimate);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
import { GlobalConfigService } from '../../config/global-config.service';
|
||||
import { unique } from '../../../util/unique';
|
||||
import { TaskService } from '../task.service';
|
||||
import { EMPTY, from, of } from 'rxjs';
|
||||
import { from, of } from 'rxjs';
|
||||
import { ProjectService } from '../../project/project.service';
|
||||
import { TagService } from '../../tag/tag.service';
|
||||
import { shortSyntax } from '../short-syntax';
|
||||
|
|
@ -102,12 +102,6 @@ export class ShortSyntaxEffects {
|
|||
),
|
||||
),
|
||||
mergeMap(([{ task, originalAction }, tags, projects, defaultProjectId]) => {
|
||||
// Guard: task may have been archived/deleted while the effect was in flight
|
||||
// (e.g., a concurrent sync applied a moveToArchive op). Skip processing
|
||||
// to prevent "Cannot read properties of undefined" errors.
|
||||
if (!task) {
|
||||
return EMPTY;
|
||||
}
|
||||
const isReplaceTagIds = originalAction.type === TaskSharedActions.updateTask.type;
|
||||
return from(
|
||||
shortSyntax(
|
||||
|
|
|
|||
|
|
@ -103,29 +103,6 @@ describe('Task Reducer', () => {
|
|||
expect(state.entities['task1']!.subTaskIds).toContain('subTask3');
|
||||
expect(state.entities['subTask3']).toEqual({ ...newSubTask, parentId: 'task1' });
|
||||
});
|
||||
|
||||
it('should roll up the parent time estimate when adding a subtask with an estimate', () => {
|
||||
const parent = createTask('parent');
|
||||
const subTask = createTask('subTask', { timeEstimate: 2.5 * 60 * 60 * 1000 });
|
||||
const state: TaskState = {
|
||||
...initialTaskState,
|
||||
ids: ['parent'],
|
||||
entities: {
|
||||
parent,
|
||||
},
|
||||
};
|
||||
|
||||
const result = taskReducer(
|
||||
state,
|
||||
fromActions.addSubTask({
|
||||
task: subTask,
|
||||
parentId: 'parent',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.entities['parent']!.subTaskIds).toEqual(['subTask']);
|
||||
expect(result.entities['parent']!.timeEstimate).toBe(2.5 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveSubTask (anchor-based)', () => {
|
||||
|
|
|
|||
|
|
@ -489,7 +489,7 @@ export const taskReducer = createReducer<TaskState>(
|
|||
state,
|
||||
);
|
||||
|
||||
const stateWithParentTask: TaskState = {
|
||||
return {
|
||||
...stateCopy,
|
||||
// update current task to new sub task if parent was current before
|
||||
...(state.currentTaskId === parentId ? { currentTaskId: task.id } : {}),
|
||||
|
|
@ -502,8 +502,6 @@ export const taskReducer = createReducer<TaskState>(
|
|||
},
|
||||
},
|
||||
};
|
||||
|
||||
return reCalcTimesForParentIfParent(parentId, stateWithParentTask);
|
||||
}),
|
||||
|
||||
on(toggleStart, (state) => {
|
||||
|
|
|
|||
|
|
@ -53,10 +53,7 @@ export class DialogGetAndEnterAuthCodeComponent implements OnDestroy {
|
|||
T: typeof T = T;
|
||||
token?: string;
|
||||
|
||||
// Always use manual code entry flow (show input field on all platforms).
|
||||
// The automatic deep-link redirect flow was reverted: it requires the
|
||||
// redirect URI to be registered in the Dropbox developer console, and
|
||||
// Android may kill the app during auth, losing the in-memory code verifier.
|
||||
// Always use manual code entry flow (show input field)
|
||||
readonly isNativePlatform = false;
|
||||
private _authCodeSub?: Subscription;
|
||||
|
||||
|
|
|
|||
|
|
@ -222,16 +222,7 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit {
|
|||
await this.syncConfigService.updateSettingsFromForm(configToSave as SyncConfig, true);
|
||||
const providerId = toSyncProviderId(this._tmpUpdatedCfg.syncProvider);
|
||||
if (providerId && this._tmpUpdatedCfg.isEnabled) {
|
||||
const { wasConfigured, authAttempted } =
|
||||
await this.syncWrapperService.configuredAuthForSyncProviderIfNecessary(
|
||||
providerId,
|
||||
);
|
||||
|
||||
// If auth was attempted (dialog opened) but not completed (cancelled or failed),
|
||||
// keep dialog open so the user can retry without losing their config.
|
||||
if (authAttempted && !wasConfigured) {
|
||||
return;
|
||||
}
|
||||
await this.syncWrapperService.configuredAuthForSyncProviderIfNecessary(providerId);
|
||||
}
|
||||
|
||||
this._matDialogRef.close();
|
||||
|
|
|
|||
|
|
@ -81,8 +81,12 @@ describe('FileBasedEncryptionService', () => {
|
|||
]);
|
||||
mockVectorClockService.getCurrentVectorClock.and.resolveTo({ testClient: 1 });
|
||||
|
||||
mockClientIdProvider = jasmine.createSpyObj('ClientIdProvider', ['loadClientId']);
|
||||
mockClientIdProvider = jasmine.createSpyObj('ClientIdProvider', [
|
||||
'loadClientId',
|
||||
'getOrGenerateClientId',
|
||||
]);
|
||||
mockClientIdProvider.loadClientId.and.resolveTo('testClient');
|
||||
mockClientIdProvider.getOrGenerateClientId.and.resolveTo('testClient');
|
||||
|
||||
mockFileBasedAdapter = jasmine.createSpyObj('FileBasedSyncAdapterService', [
|
||||
'createAdapter',
|
||||
|
|
@ -150,12 +154,12 @@ describe('FileBasedEncryptionService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should throw when client ID is not available', async () => {
|
||||
mockClientIdProvider.loadClientId.and.resolveTo(null);
|
||||
it('should use getOrGenerateClientId from the provider', async () => {
|
||||
mockClientIdProvider.getOrGenerateClientId.and.resolveTo('B_regen');
|
||||
|
||||
await expectAsync(service.enableEncryption('my-password')).toBeRejectedWithError(
|
||||
'Client ID not available',
|
||||
);
|
||||
// Should NOT throw — getOrGenerateClientId handles null/invalid IDs internally
|
||||
await expectAsync(service.enableEncryption('my-password')).toBeResolved();
|
||||
expect(mockClientIdProvider.getOrGenerateClientId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should upload encrypted snapshot before saving config', async () => {
|
||||
|
|
|
|||
|
|
@ -74,11 +74,7 @@ export class FileBasedEncryptionService {
|
|||
|
||||
const state = await this._stateSnapshotService.getStateSnapshotAsync();
|
||||
const vectorClock = await this._vectorClockService.getCurrentVectorClock();
|
||||
const clientId = await this._clientIdProvider.loadClientId();
|
||||
|
||||
if (!clientId) {
|
||||
throw new Error('Client ID not available');
|
||||
}
|
||||
const clientId = await this._clientIdProvider.getOrGenerateClientId();
|
||||
|
||||
const existingCfg = await fileProvider.privateCfg.load();
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ describe('SnapshotUploadService', () => {
|
|||
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
|
||||
let mockStateSnapshotService: jasmine.SpyObj<StateSnapshotService>;
|
||||
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
|
||||
let mockClientIdProvider: { loadClientId: jasmine.Spy };
|
||||
let mockClientIdProvider: { loadClientId: jasmine.Spy; getOrGenerateClientId: jasmine.Spy };
|
||||
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
|
||||
let mockSyncProvider: jasmine.SpyObj<
|
||||
SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable
|
||||
|
|
@ -70,6 +70,9 @@ describe('SnapshotUploadService', () => {
|
|||
|
||||
mockClientIdProvider = {
|
||||
loadClientId: jasmine.createSpy('loadClientId').and.resolveTo('test-client-id'),
|
||||
getOrGenerateClientId: jasmine
|
||||
.createSpy('getOrGenerateClientId')
|
||||
.and.resolveTo('test-client-id'),
|
||||
};
|
||||
|
||||
mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [
|
||||
|
|
@ -138,11 +141,13 @@ describe('SnapshotUploadService', () => {
|
|||
expect(result.existingCfg).toEqual({ encryptKey: 'test' } as any);
|
||||
});
|
||||
|
||||
it('should throw when client ID is not available', async () => {
|
||||
mockClientIdProvider.loadClientId.and.resolveTo(null);
|
||||
await expectAsync(service.gatherSnapshotData()).toBeRejectedWithError(
|
||||
'Client ID not available',
|
||||
);
|
||||
it('should regenerate client ID when getOrGenerateClientId is used', async () => {
|
||||
mockClientIdProvider.getOrGenerateClientId.and.resolveTo('B_regen');
|
||||
|
||||
const result = await service.gatherSnapshotData();
|
||||
|
||||
expect(mockClientIdProvider.getOrGenerateClientId).toHaveBeenCalled();
|
||||
expect(result.clientId).toBe('B_regen');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export class SnapshotUploadService {
|
|||
* - Client ID
|
||||
*
|
||||
* @param logPrefix - Optional prefix for log messages
|
||||
* @throws Error if validation fails or client ID is not available
|
||||
* @throws Error if provider validation fails
|
||||
*/
|
||||
async gatherSnapshotData(logPrefix?: string): Promise<SnapshotUploadData> {
|
||||
const prefix = logPrefix ? `${logPrefix}: ` : '';
|
||||
|
|
@ -120,11 +120,7 @@ export class SnapshotUploadService {
|
|||
SyncLog.normal(`${prefix}Getting current state...`);
|
||||
const state = await this._stateSnapshotService.getStateSnapshotAsync();
|
||||
const vectorClock = await this._vectorClockService.getCurrentVectorClock();
|
||||
const clientId = await this._clientIdProvider.loadClientId();
|
||||
|
||||
if (!clientId) {
|
||||
throw new Error('Client ID not available');
|
||||
}
|
||||
const clientId = await this._clientIdProvider.getOrGenerateClientId();
|
||||
|
||||
return {
|
||||
syncProvider,
|
||||
|
|
|
|||
|
|
@ -206,10 +206,7 @@ export class SyncTriggerService {
|
|||
shareReplay(1),
|
||||
);
|
||||
|
||||
getSyncTrigger$(
|
||||
syncInterval: number = SYNC_DEFAULT_AUDIT_TIME,
|
||||
useIntervalTimer = false,
|
||||
): Observable<unknown> {
|
||||
getSyncTrigger$(syncInterval: number = SYNC_DEFAULT_AUDIT_TIME): Observable<unknown> {
|
||||
const _immediateSyncTrigger$: Observable<string> = IS_ANDROID_WEB_VIEW
|
||||
? // ANDROID ONLY
|
||||
merge(
|
||||
|
|
@ -234,13 +231,6 @@ export class SyncTriggerService {
|
|||
this._isOnlineTrigger$,
|
||||
this._onIdleTrigger$,
|
||||
this._onElectronResumeTrigger$,
|
||||
// Periodic interval timer: fires every syncInterval ms to detect external file
|
||||
// changes (e.g. Syncthing on Linux) even without user activity.
|
||||
// Only for file-based providers — SuperSync uses WebSocket push and doesn't need polling.
|
||||
// Fixes: Linux auto-sync ignoring interval (#4783)
|
||||
...(useIntervalTimer
|
||||
? [timer(syncInterval, syncInterval).pipe(mapTo('I_INTERVAL_TIMER'))]
|
||||
: []),
|
||||
);
|
||||
return merge(
|
||||
// once immediately
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { signal } from '@angular/core';
|
||||
import { T } from '../../t.const';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { BehaviorSubject, firstValueFrom, of } from 'rxjs';
|
||||
import { SyncWrapperService } from './sync-wrapper.service';
|
||||
|
|
@ -28,6 +29,8 @@ import {
|
|||
SyncAlreadyInProgressError,
|
||||
LocalDataConflictError,
|
||||
MissingRefreshTokenAPIError,
|
||||
JsonParseError,
|
||||
SyncDataCorruptedError,
|
||||
} from '../../op-log/core/errors/sync-errors';
|
||||
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';
|
||||
|
||||
|
|
@ -951,6 +954,70 @@ describe('SyncWrapperService', () => {
|
|||
expect(mockSnackService.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle JsonParseError with force-overwrite action and corrupted-data message (#5574, #4616)', async () => {
|
||||
mockSyncService.downloadRemoteOps.and.returnValue(
|
||||
Promise.reject(new JsonParseError(new SyntaxError('Unexpected end of JSON'), '')),
|
||||
);
|
||||
|
||||
const result = await service.sync();
|
||||
|
||||
expect(result).toBe('HANDLED_ERROR');
|
||||
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
|
||||
expect(mockSnackService.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
msg: T.F.SYNC.S.ERROR_REMOTE_FILE_CORRUPTED,
|
||||
type: 'ERROR',
|
||||
actionFn: jasmine.any(Function),
|
||||
actionStr: jasmine.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle SyncDataCorruptedError with version-mismatch message (no force-overwrite)', async () => {
|
||||
mockSyncService.downloadRemoteOps.and.returnValue(
|
||||
Promise.reject(
|
||||
new SyncDataCorruptedError('Unsupported version: 1', 'sync-data.json'),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await service.sync();
|
||||
|
||||
expect(result).toBe('HANDLED_ERROR');
|
||||
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
|
||||
// Must show the version-mismatch message (not generic "corrupted data")
|
||||
// Must NOT offer force-upload: remote may be a newer version from another client
|
||||
expect(mockSnackService.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
msg: T.F.SYNC.S.ERROR_SYNC_VERSION_MISMATCH,
|
||||
type: 'ERROR',
|
||||
}),
|
||||
);
|
||||
const callArgs = mockSnackService.open.calls.mostRecent().args[0];
|
||||
expect(callArgs['actionFn']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle SyncDataCorruptedError for newer remote version (no force-overwrite, same message)', async () => {
|
||||
// version 3 > FILE_VERSION 2 — remote is from a future app version
|
||||
mockSyncService.downloadRemoteOps.and.returnValue(
|
||||
Promise.reject(
|
||||
new SyncDataCorruptedError('Unsupported version: 3', 'sync-data.json'),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await service.sync();
|
||||
|
||||
expect(result).toBe('HANDLED_ERROR');
|
||||
expect(mockSnackService.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
msg: T.F.SYNC.S.ERROR_SYNC_VERSION_MISMATCH,
|
||||
type: 'ERROR',
|
||||
}),
|
||||
);
|
||||
// No force-upload button — overwriting a newer remote would destroy data
|
||||
const callArgs = mockSnackService.open.calls.mostRecent().args[0];
|
||||
expect(callArgs['actionFn']).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('LocalDataConflictError handling', () => {
|
||||
beforeEach(() => {
|
||||
mockOpLogStore.getVectorClockEntry.and.returnValue(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
MissingRefreshTokenAPIError,
|
||||
HttpNotOkAPIError,
|
||||
EmptyRemoteBodySPError,
|
||||
LegacySyncFormatDetectedError,
|
||||
JsonParseError,
|
||||
SyncDataCorruptedError,
|
||||
} from '../../op-log/core/errors/sync-errors';
|
||||
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';
|
||||
import { SyncConfig } from '../../features/config/global-config.model';
|
||||
|
|
@ -602,8 +603,8 @@ export class SyncWrapperService {
|
|||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (error instanceof EmptyRemoteBodySPError) {
|
||||
// Remote file returned empty body (e.g. Koofr WebDAV corrupted file).
|
||||
// Force overwrite is safe here: local data is intact, remote is empty.
|
||||
// Remote file returned an empty body (e.g. Koofr WebDAV corrupted file).
|
||||
// Force overwrite is safe: local data is intact, remote is empty.
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
this._snackService.open({
|
||||
msg: T.F.SYNC.S.ERROR_REMOTE_FILE_EMPTY,
|
||||
|
|
@ -613,6 +614,30 @@ export class SyncWrapperService {
|
|||
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (error instanceof JsonParseError) {
|
||||
// Remote JSON is unparseable (e.g. truncated write, encoding issue).
|
||||
// Force overwrite is safe: local data is intact, remote cannot be parsed.
|
||||
// Issues: #5574, #4616.
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
this._snackService.open({
|
||||
msg: T.F.SYNC.S.ERROR_REMOTE_FILE_CORRUPTED,
|
||||
type: 'ERROR',
|
||||
config: { duration: 12000 },
|
||||
actionFn: async () => this.forceUpload(),
|
||||
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (error instanceof SyncDataCorruptedError) {
|
||||
// Remote file format version is incompatible (could be older or newer than local).
|
||||
// Do NOT offer force-upload: if remote is newer, overwriting would destroy newer data.
|
||||
// Users should ensure all devices run the same app version.
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
this._snackService.open({
|
||||
msg: T.F.SYNC.S.ERROR_SYNC_VERSION_MISMATCH,
|
||||
type: 'ERROR',
|
||||
config: { duration: 12000 },
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (error instanceof HttpNotOkAPIError && error.response.status === 423) {
|
||||
// HTTP 423 Locked: WebDAV server holds a file lock.
|
||||
// Do NOT offer force overwrite — the PUT will also receive 423.
|
||||
|
|
@ -673,14 +698,6 @@ export class SyncWrapperService {
|
|||
},
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (error instanceof LegacySyncFormatDetectedError) {
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
this._snackService.open({
|
||||
msg: T.F.SYNC.S.LEGACY_FORMAT_DETECTED,
|
||||
type: 'ERROR',
|
||||
config: { duration: 20000 },
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (this._isPermissionError(error)) {
|
||||
this._snackService.open({
|
||||
msg: this._getPermissionErrorMessage(),
|
||||
|
|
@ -774,20 +791,20 @@ export class SyncWrapperService {
|
|||
async configuredAuthForSyncProviderIfNecessary(
|
||||
providerId: SyncProviderId,
|
||||
force = false,
|
||||
): Promise<{ wasConfigured: boolean; authAttempted: boolean }> {
|
||||
): Promise<{ wasConfigured: boolean }> {
|
||||
const provider = await this._providerManager.getProviderById(providerId);
|
||||
|
||||
if (!provider) {
|
||||
return { wasConfigured: false, authAttempted: false };
|
||||
return { wasConfigured: false };
|
||||
}
|
||||
|
||||
if (!provider.getAuthHelper) {
|
||||
return { wasConfigured: false, authAttempted: false };
|
||||
return { wasConfigured: false };
|
||||
}
|
||||
|
||||
if (!force && (await provider.isReady())) {
|
||||
SyncLog.warn('Provider already configured');
|
||||
return { wasConfigured: false, authAttempted: false };
|
||||
return { wasConfigured: false };
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -816,9 +833,9 @@ export class SyncWrapperService {
|
|||
setTimeout(() => {
|
||||
this.sync();
|
||||
}, 1000);
|
||||
return { wasConfigured: true, authAttempted: true };
|
||||
return { wasConfigured: true };
|
||||
} else {
|
||||
return { wasConfigured: false, authAttempted: true };
|
||||
return { wasConfigured: false };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -832,9 +849,9 @@ export class SyncWrapperService {
|
|||
type: 'ERROR',
|
||||
config: { duration: 0 },
|
||||
});
|
||||
return { wasConfigured: false, authAttempted: true };
|
||||
return { wasConfigured: false };
|
||||
}
|
||||
return { wasConfigured: false, authAttempted: false };
|
||||
return { wasConfigured: false };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -135,14 +135,10 @@ export class SyncEffects {
|
|||
combineLatest([
|
||||
this._syncWrapperService.isEnabledAndReady$,
|
||||
this._syncWrapperService.syncInterval$,
|
||||
this._syncWrapperService.syncProviderId$,
|
||||
]).pipe(
|
||||
switchMap(([isEnabledAndReady, syncInterval, providerId]) =>
|
||||
switchMap(([isEnabledAndReady, syncInterval]) =>
|
||||
isEnabledAndReady && syncInterval
|
||||
? this._syncTriggerService.getSyncTrigger$(
|
||||
syncInterval,
|
||||
providerId !== SyncProviderId.SuperSync,
|
||||
)
|
||||
? this._syncTriggerService.getSyncTrigger$(syncInterval)
|
||||
: EMPTY,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -566,19 +566,18 @@ export class StorageQuotaExceededError extends Error {
|
|||
}
|
||||
|
||||
/**
|
||||
* Thrown when the remote sync provider has legacy pfapi files (__meta_) but no
|
||||
* sync-data.json. This means a v16.x client is still writing to the same provider
|
||||
* using the old per-file format. Cross-version sync is not supported — both devices
|
||||
* must run the same app version for sync to work.
|
||||
* Thrown when sync data is incompatible with the expected format version.
|
||||
* This can occur when the remote file was written by a different (older or newer)
|
||||
* version of the app. Force-uploading is unsafe in this case because the remote
|
||||
* may be in a newer format.
|
||||
*/
|
||||
export class LegacySyncFormatDetectedError extends Error {
|
||||
override name = 'LegacySyncFormatDetectedError';
|
||||
export class SyncDataCorruptedError extends Error {
|
||||
override name = 'SyncDataCorruptedError';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Sync format mismatch: the remote storage was last written by an older app version ' +
|
||||
'(v16.x or earlier) that uses a different sync format. Please update all your ' +
|
||||
'devices to the same app version so they use the same sync format.',
|
||||
);
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly filePath: string,
|
||||
) {
|
||||
super(`Sync data incompatible at ${filePath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,12 +270,10 @@ export const POST_SYNC_COOLDOWN_MS = 2000;
|
|||
* Transient failures (file locks, temporary I/O issues) may resolve on retry.
|
||||
* @see https://github.com/johannesjo/super-productivity/issues/6255
|
||||
*/
|
||||
export const IDB_OPEN_RETRIES = 4;
|
||||
export const IDB_OPEN_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Base delay for IndexedDB open retry exponential backoff (milliseconds).
|
||||
* With IDB_OPEN_RETRIES=4: delays are 500ms, 1000ms, 2000ms, 4000ms for retries 1-4.
|
||||
* Extended from 3 to 4 to give more time for OS/Flatpak storage to initialize on session login.
|
||||
* @see https://github.com/super-productivity/super-productivity/issues/7191
|
||||
* With IDB_OPEN_RETRIES=3: delays are 500ms, 1000ms, 2000ms for retries 1, 2, 3.
|
||||
*/
|
||||
export const IDB_OPEN_RETRY_BASE_DELAY_MS = 500;
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ import { loadAllData } from '../../root-store/meta/load-all-data.action';
|
|||
import { bulkApplyHydrationOperations } from '../apply/bulk-hydration.action';
|
||||
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
|
||||
import { MAX_VECTOR_CLOCK_SIZE } from '@sp/shared-schema';
|
||||
import { IndexedDBOpenError } from '../core/errors/indexed-db-open.error';
|
||||
import { IDB_OPEN_ERROR_RELOAD_KEY } from './operation-log-hydrator.service';
|
||||
|
||||
describe('OperationLogHydratorService', () => {
|
||||
let service: OperationLogHydratorService;
|
||||
|
|
@ -1321,65 +1319,4 @@ describe('OperationLogHydratorService', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IndexedDB open error handling', () => {
|
||||
let reloadSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
if (!jasmine.isSpy(window.alert)) {
|
||||
spyOn(window, 'alert');
|
||||
}
|
||||
(window.alert as jasmine.Spy).calls.reset();
|
||||
reloadSpy = spyOn(service as any, '_triggerReload');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
});
|
||||
|
||||
it('should silently auto-reload for backing store error on first occurrence', async () => {
|
||||
const err = new IndexedDBOpenError(
|
||||
new Error('Internal error opening backing store for indexedDB.open'),
|
||||
);
|
||||
mockRecoveryService.recoverPendingRemoteOps.and.rejectWith(err);
|
||||
|
||||
await expectAsync(service.hydrateStore()).toBeRejected();
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sessionStorage.getItem(IDB_OPEN_ERROR_RELOAD_KEY)).toBe('1');
|
||||
// No dialog on first attempt — autostart users aren't watching
|
||||
expect(window.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show recovery dialog and NOT auto-reload when already reloaded once', async () => {
|
||||
sessionStorage.setItem(IDB_OPEN_ERROR_RELOAD_KEY, '1');
|
||||
const err = new IndexedDBOpenError(
|
||||
new Error('Internal error opening backing store for indexedDB.open'),
|
||||
);
|
||||
mockRecoveryService.recoverPendingRemoteOps.and.rejectWith(err);
|
||||
|
||||
await expectAsync(service.hydrateStore()).toBeRejected();
|
||||
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
expect(window.alert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT auto-reload for non-backing-store IDB open errors', async () => {
|
||||
const err = new IndexedDBOpenError(new Error('QuotaExceededError'));
|
||||
mockRecoveryService.recoverPendingRemoteOps.and.rejectWith(err);
|
||||
|
||||
await expectAsync(service.hydrateStore()).toBeRejected();
|
||||
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear the reload key after successful hydration', async () => {
|
||||
sessionStorage.setItem(IDB_OPEN_ERROR_RELOAD_KEY, '1');
|
||||
// Successful hydration — no errors thrown
|
||||
await service.hydrateStore();
|
||||
|
||||
expect(sessionStorage.getItem(IDB_OPEN_ERROR_RELOAD_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,13 +27,6 @@ import { MAX_CONFLICT_RETRY_ATTEMPTS } from '../core/operation-log.const';
|
|||
import { AppDataComplete } from '../model/model-config';
|
||||
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
|
||||
import { limitVectorClockSize } from '../../core/util/vector-clock';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
|
||||
/**
|
||||
* sessionStorage key used to track auto-reload attempts after IndexedDB backing store errors.
|
||||
* Exported for use in tests.
|
||||
*/
|
||||
export const IDB_OPEN_ERROR_RELOAD_KEY = 'sp_idb_open_reload_attempt';
|
||||
|
||||
/**
|
||||
* Handles the hydration (loading) of the application state from the operation log
|
||||
|
|
@ -124,7 +117,6 @@ export class OperationLogHydratorService {
|
|||
'OperationLogHydratorService: Snapshot is invalid/corrupted. Attempting recovery...',
|
||||
);
|
||||
await this.recoveryService.attemptRecovery();
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -288,7 +280,6 @@ export class OperationLogHydratorService {
|
|||
OpLog.normal(
|
||||
'OperationLogHydratorService: Fresh install detected. No data to load.',
|
||||
);
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -371,11 +362,6 @@ export class OperationLogHydratorService {
|
|||
// Retry any failed remote ops from previous conflict resolution attempts
|
||||
// Now that state is fully hydrated, dependencies might be resolved
|
||||
await this.retryFailedRemoteOps();
|
||||
|
||||
// Clear the auto-reload guard so that a fresh backing-store error in the same
|
||||
// tab session gets the auto-reload treatment again rather than going straight
|
||||
// to the manual recovery dialog.
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
} catch (e) {
|
||||
OpLog.err('OperationLogHydratorService: Error during hydration', e);
|
||||
|
||||
|
|
@ -672,32 +658,6 @@ export class OperationLogHydratorService {
|
|||
? error.originalError.message
|
||||
: String(error.originalError);
|
||||
|
||||
// Hoist platform detection — used in both branches below to avoid computing twice
|
||||
const isFlatpak = IS_ELECTRON && window.ea?.isFlatpak?.();
|
||||
const isSnap = !isFlatpak && IS_ELECTRON && window.ea?.isSnap?.();
|
||||
|
||||
// For backing-store errors (common during Linux session startup with autostart),
|
||||
// auto-reload once after the user dismisses the dialog. By the time the dialog
|
||||
// is dismissed the OS / Flatpak sandbox will usually have finished initializing.
|
||||
// A sessionStorage counter prevents an infinite reload loop on genuine errors.
|
||||
if (error.isBackingStoreError) {
|
||||
const reloadCount = +(sessionStorage.getItem(IDB_OPEN_ERROR_RELOAD_KEY) || '0');
|
||||
if (reloadCount === 0) {
|
||||
// Silent auto-reload on first occurrence — most likely a transient startup
|
||||
// timing issue (Flatpak sandbox not ready, stale LOCK file). The user is
|
||||
// typically not watching (autostart scenario), so a blocking dialog requiring
|
||||
// a click before the reload is unnecessary friction. If the reload fixes it,
|
||||
// the user never needs to know. If it fails again, the dialog below runs.
|
||||
OpLog.warn(
|
||||
'IndexedDB backing-store error on first attempt — triggering silent auto-reload',
|
||||
);
|
||||
sessionStorage.setItem(IDB_OPEN_ERROR_RELOAD_KEY, '1');
|
||||
this._triggerReload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Second failure, or non-backing-store error: show full manual recovery instructions.
|
||||
let message =
|
||||
'Database Error - Cannot Load Data\n\n' +
|
||||
'Super Productivity cannot open its database. ' +
|
||||
|
|
@ -711,11 +671,7 @@ export class OperationLogHydratorService {
|
|||
'Recovery steps:\n' +
|
||||
'1. Close ALL browser tabs and windows\n' +
|
||||
'2. Restart the app\n' +
|
||||
(isFlatpak
|
||||
? '3. If using Linux Flatpak with autostart, try disabling autostart and launching manually\n'
|
||||
: isSnap
|
||||
? '3. If using Linux Snap, try: snap set core experimental.refresh-app-awareness=true\n'
|
||||
: '3. If using Linux with autostart, try disabling autostart and launching manually\n') +
|
||||
'3. If using Linux Snap, try: snap set core experimental.refresh-app-awareness=true\n' +
|
||||
'4. If issue persists, check available disk space\n\n';
|
||||
}
|
||||
|
||||
|
|
@ -726,16 +682,4 @@ export class OperationLogHydratorService {
|
|||
|
||||
alertDialog(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an app reload. Uses Electron IPC in Electron context, browser reload otherwise.
|
||||
* Extracted as a method to allow spying in unit tests.
|
||||
*/
|
||||
private _triggerReload(): void {
|
||||
if (IS_ELECTRON) {
|
||||
window.ea.reloadMainWin();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ describe('OperationLogStoreService', () => {
|
|||
let vectorClockService: VectorClockService;
|
||||
const mockClientIdProvider: ClientIdProvider = {
|
||||
loadClientId: () => Promise.resolve('testClient'),
|
||||
getOrGenerateClientId: () => Promise.resolve('testClient'),
|
||||
};
|
||||
|
||||
// Helper to create test operations
|
||||
|
|
@ -2063,6 +2064,32 @@ describe('OperationLogStoreService', () => {
|
|||
expect(clock).toEqual({ remoteClient: 10 });
|
||||
});
|
||||
|
||||
it('should not throw and store merged clock when loadClientId returns null (invalid stored ID)', async () => {
|
||||
// Simulate issue #6197: stored clientId has an invalid format, loadClientId() returns null.
|
||||
// mergeRemoteOpClocks must not throw — it falls back to storing the full merged clock
|
||||
// without pruning (suboptimal but safe).
|
||||
spyOn(mockClientIdProvider, 'loadClientId').and.resolveTo(null);
|
||||
await service.setVectorClock({ localClient: 3 });
|
||||
|
||||
const remoteOps = [
|
||||
createTestOperation({
|
||||
clientId: 'remoteClient',
|
||||
vectorClock: { remoteClient: 7, localClient: 2 },
|
||||
}),
|
||||
];
|
||||
|
||||
await expectAsync(service.mergeRemoteOpClocks(remoteOps)).toBeResolved();
|
||||
|
||||
// The merged clock must contain all entries (no pruning without a clientId, but no data loss)
|
||||
const clock = await service.getVectorClock();
|
||||
expect(clock).toEqual(
|
||||
jasmine.objectContaining({
|
||||
remoteClient: 7,
|
||||
localClient: 3, // local was higher (3 > 2)
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should REPLACE clock for SYNC_IMPORT (not merge into old clock)', async () => {
|
||||
// SYNC_IMPORT is a clean slate — old clock entries are irrelevant.
|
||||
// Merging would cause clock bloat → server pruning → CONCURRENT comparisons.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,11 @@ describe('SyncHydrationService', () => {
|
|||
mockStateSnapshotService.getAllSyncModelDataFromStoreAsync.and.resolveTo({} as any);
|
||||
// Default: state cache has no vector clock (simulates fresh start)
|
||||
mockOpLogStore.loadStateCache.and.resolveTo(null);
|
||||
mockClientIdService = jasmine.createSpyObj('ClientIdService', ['loadClientId']);
|
||||
mockClientIdService = jasmine.createSpyObj('ClientIdService', [
|
||||
'loadClientId',
|
||||
'generateNewClientId',
|
||||
'getOrGenerateClientId',
|
||||
]);
|
||||
mockVectorClockService = jasmine.createSpyObj('VectorClockService', [
|
||||
'getCurrentVectorClock',
|
||||
]);
|
||||
|
|
@ -78,6 +82,7 @@ describe('SyncHydrationService', () => {
|
|||
|
||||
const setupDefaultMocks = (): void => {
|
||||
mockClientIdService.loadClientId.and.resolveTo('localClient');
|
||||
mockClientIdService.getOrGenerateClientId.and.resolveTo('localClient');
|
||||
mockVectorClockService.getCurrentVectorClock.and.resolveTo({ localClient: 5 });
|
||||
mockOpLogStore.append.and.resolveTo(undefined);
|
||||
mockOpLogStore.getLastSeq.and.resolveTo(10);
|
||||
|
|
@ -263,12 +268,17 @@ describe('SyncHydrationService', () => {
|
|||
expect(globalConfig['lang']).toBe('en');
|
||||
});
|
||||
|
||||
it('should throw when clientId cannot be loaded', async () => {
|
||||
mockClientIdService.loadClientId.and.resolveTo(null);
|
||||
it('should use getOrGenerateClientId and propagate the ID to SYNC_IMPORT', async () => {
|
||||
// Simulate issue #6197: getOrGenerateClientId() generates a fresh ID when stored is invalid.
|
||||
// Service must use the returned ID — not throw, not leave the op with null/undefined.
|
||||
mockClientIdService.getOrGenerateClientId.and.resolveTo('B_regen');
|
||||
|
||||
await expectAsync(service.hydrateFromRemoteSync({})).toBeRejectedWithError(
|
||||
/Failed to load clientId/,
|
||||
);
|
||||
await expectAsync(service.hydrateFromRemoteSync({})).toBeResolved();
|
||||
expect(mockClientIdService.getOrGenerateClientId).toHaveBeenCalled();
|
||||
|
||||
// Verify the SYNC_IMPORT operation carries the ID returned by getOrGenerateClientId
|
||||
const appendCall = mockOpLogStore.append.calls.mostRecent();
|
||||
expect(appendCall.args[0].clientId).toBe('B_regen');
|
||||
});
|
||||
|
||||
it('should save state cache after appending operation', async () => {
|
||||
|
|
|
|||
|
|
@ -122,11 +122,8 @@ export class SyncHydrationService {
|
|||
: '(from state snapshot)',
|
||||
);
|
||||
|
||||
// 3. Get client ID for vector clock
|
||||
const clientId = await this.clientIdService.loadClientId();
|
||||
if (!clientId) {
|
||||
throw new Error('Failed to load clientId - cannot create SYNC_IMPORT operation');
|
||||
}
|
||||
// 3. Get client ID for vector clock (regenerate if missing or invalid)
|
||||
const clientId = await this.clientIdService.getOrGenerateClientId();
|
||||
|
||||
// 4. Create SYNC_IMPORT operation with merged clock
|
||||
// CRITICAL: The SYNC_IMPORT's clock must include ALL known clients, not just local ones.
|
||||
|
|
|
|||
|
|
@ -314,12 +314,11 @@ export class Dropbox implements SyncProviderServiceInterface<SyncProviderId.Drop
|
|||
/**
|
||||
* Gets the OAuth redirect URI.
|
||||
*
|
||||
* Returns null on all platforms to use Dropbox's manual code entry flow.
|
||||
* User copies the code from Dropbox's page and pastes it manually.
|
||||
* This is the most reliable cross-platform approach — the automatic deep-link
|
||||
* redirect flow was reverted because the Dropbox developer console redirect URI
|
||||
* registration is uncertain and Android may kill the app during auth, losing
|
||||
* the in-memory code verifier.
|
||||
* Always returns null to use Dropbox's manual code entry flow.
|
||||
* User must copy the code from Dropbox's page and paste it manually.
|
||||
* This works reliably across all platforms (web, Electron, Android, iOS).
|
||||
*
|
||||
* @returns null for manual code entry flow
|
||||
*/
|
||||
private _getRedirectUri(): string | null {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -161,10 +161,6 @@ describe('FileBasedSyncAdapterService', () => {
|
|||
'getFileRev',
|
||||
]);
|
||||
mockProvider.id = SyncProviderId.WebDAV;
|
||||
// Default: no legacy __meta_ file present → treat missing sync-data.json as fresh start
|
||||
mockProvider.getFileRev.and.callFake(async (_path: string) => {
|
||||
throw new RemoteFileNotFoundAPIError('not found');
|
||||
});
|
||||
|
||||
// Clear localStorage to prevent state leaking between tests
|
||||
// Note: Must clear both old keys (for migration code path) and new atomic key
|
||||
|
|
@ -1883,36 +1879,4 @@ describe('FileBasedSyncAdapterService', () => {
|
|||
expect(uploadedData.oldestOpSyncVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy pfapi format detection (v16.x cross-version guard)', () => {
|
||||
it('throws LegacySyncFormatDetectedError when __meta_ exists but sync-data.json does not', async () => {
|
||||
// Simulate a v16.x provider: __meta_ present, sync-data.json absent
|
||||
mockProvider.downloadFile.and.rejectWith(
|
||||
new RemoteFileNotFoundAPIError('not found'),
|
||||
);
|
||||
mockProvider.getFileRev.and.callFake(async (path: string) => {
|
||||
if (path === FILE_BASED_SYNC_CONSTANTS.LEGACY_META_FILE)
|
||||
return { rev: 'rev-meta' };
|
||||
throw new RemoteFileNotFoundAPIError('not found');
|
||||
});
|
||||
|
||||
await expectAsync(adapter.downloadOps(0)).toBeRejectedWithError(
|
||||
/Sync format mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats missing __meta_ as a genuine fresh start (not an error)', async () => {
|
||||
// Both sync-data.json and __meta_ absent → fresh install
|
||||
mockProvider.downloadFile.and.callFake(async (_path: string) => {
|
||||
throw new RemoteFileNotFoundAPIError('not found');
|
||||
});
|
||||
mockProvider.getFileRev.and.callFake(async (_path: string) => {
|
||||
throw new RemoteFileNotFoundAPIError('not found');
|
||||
});
|
||||
mockProvider.uploadFile.and.returnValue(Promise.resolve({ rev: 'rev-1' }));
|
||||
|
||||
const result = await adapter.downloadOps(0);
|
||||
expect(result.ops).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import {
|
|||
import { OpLog } from '../../../core/log';
|
||||
import {
|
||||
InvalidDataSPError,
|
||||
LegacySyncFormatDetectedError,
|
||||
RemoteFileNotFoundAPIError,
|
||||
UploadRevToMatchMismatchAPIError,
|
||||
} from '../../core/errors/sync-errors';
|
||||
|
|
@ -939,12 +938,6 @@ export class FileBasedSyncAdapterService {
|
|||
|
||||
/**
|
||||
* Downloads and decrypts the sync file.
|
||||
*
|
||||
* When sync-data.json is not found, checks for a legacy __meta_ file (written
|
||||
* by v16.x pfapi clients) and throws LegacySyncFormatDetectedError instead of
|
||||
* treating the missing file as a fresh start. This prevents silent divergence
|
||||
* when a new client first syncs to a provider still used by an old client.
|
||||
*
|
||||
* @returns The sync data and its revision (ETag) for conditional upload
|
||||
*/
|
||||
private async _downloadSyncFile(
|
||||
|
|
@ -952,27 +945,7 @@ export class FileBasedSyncAdapterService {
|
|||
cfg: EncryptAndCompressCfg,
|
||||
encryptKey: string | undefined,
|
||||
): Promise<{ data: FileBasedSyncData; rev: string }> {
|
||||
let response: Awaited<ReturnType<typeof provider.downloadFile>>;
|
||||
try {
|
||||
response = await provider.downloadFile(FILE_BASED_SYNC_CONSTANTS.SYNC_FILE);
|
||||
} catch (e) {
|
||||
if (e instanceof RemoteFileNotFoundAPIError) {
|
||||
// sync-data.json not found. Check for a legacy pfapi __meta_ file before
|
||||
// treating this as a fresh start — a v16.x device may be writing to the
|
||||
// same provider, causing silent divergence if we proceed.
|
||||
let legacyFileFound = false;
|
||||
try {
|
||||
await provider.getFileRev(FILE_BASED_SYNC_CONSTANTS.LEGACY_META_FILE, null);
|
||||
legacyFileFound = true;
|
||||
} catch (innerE) {
|
||||
if (!(innerE instanceof RemoteFileNotFoundAPIError)) throw innerE;
|
||||
// __meta_ not found either → genuine fresh start
|
||||
}
|
||||
if (legacyFileFound) throw new LegacySyncFormatDetectedError();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const response = await provider.downloadFile(FILE_BASED_SYNC_CONSTANTS.SYNC_FILE);
|
||||
const data =
|
||||
await this._encryptAndCompressHandler.decompressAndDecryptData<FileBasedSyncData>(
|
||||
cfg,
|
||||
|
|
|
|||
|
|
@ -99,18 +99,9 @@ export interface FileBasedSyncData {
|
|||
oldestOpSyncVersion?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when sync data file is corrupted or invalid.
|
||||
*/
|
||||
export class SyncDataCorruptedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly filePath: string,
|
||||
) {
|
||||
super(`Sync data corrupted at ${filePath}: ${message}`);
|
||||
this.name = 'SyncDataCorruptedError';
|
||||
}
|
||||
}
|
||||
// Re-exported from the shared errors module for backward compatibility.
|
||||
// New code should import directly from sync-errors.
|
||||
export { SyncDataCorruptedError } from '../../../op-log/core/errors/sync-errors';
|
||||
|
||||
/**
|
||||
* Constants for file-based sync
|
||||
|
|
@ -139,11 +130,4 @@ export const FILE_BASED_SYNC_CONSTANTS = {
|
|||
|
||||
/** Base delay in ms for exponential backoff between retries */
|
||||
RETRY_BASE_DELAY_MS: 500,
|
||||
|
||||
/**
|
||||
* Legacy PFAPI metadata file name written by v16.x clients.
|
||||
* Its presence on a provider (without sync-data.json) signals a version mismatch
|
||||
* where the old client is still writing and the new client must not silently diverge.
|
||||
*/
|
||||
LEGACY_META_FILE: '__meta_',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ export interface FileAdapter {
|
|||
writeFile(filePath: string, dataStr: string): Promise<void>;
|
||||
deleteFile(filePath: string): Promise<void>;
|
||||
checkDirExists?(dirPath: string): Promise<boolean>;
|
||||
listFiles?(dirPath: string): Promise<string[]>;
|
||||
listFiles?(dirPath: string): Promise<string[]>; // NEW
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,11 @@ import {
|
|||
} from '../sync-providers/provider.interface';
|
||||
import { SyncProviderId } from '../sync-providers/provider.const';
|
||||
|
||||
/** Provider IDs that use file-based operation sync (WebDAV, Dropbox, LocalFile, Nextcloud) */
|
||||
/** Provider IDs that use file-based operation sync (WebDAV, Dropbox, LocalFile) */
|
||||
const FILE_BASED_PROVIDER_IDS: Set<SyncProviderId> = new Set([
|
||||
SyncProviderId.WebDAV,
|
||||
SyncProviderId.Dropbox,
|
||||
SyncProviderId.LocalFile,
|
||||
SyncProviderId.Nextcloud,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -29,7 +28,7 @@ export const isOperationSyncCapable = (
|
|||
|
||||
/**
|
||||
* Type guard to check if a provider uses file-based operation sync.
|
||||
* File-based providers (WebDAV, Dropbox, LocalFile, Nextcloud) use file storage for sync.
|
||||
* File-based providers (WebDAV, Dropbox, LocalFile) use file storage for sync.
|
||||
*/
|
||||
export const isFileBasedProvider = (
|
||||
provider: SyncProviderBase<SyncProviderId>,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ describe('CLIENT_ID_PROVIDER', () => {
|
|||
let mockClientIdService: jasmine.SpyObj<ClientIdService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClientIdService = jasmine.createSpyObj('ClientIdService', ['loadClientId']);
|
||||
mockClientIdService = jasmine.createSpyObj('ClientIdService', [
|
||||
'loadClientId',
|
||||
'generateNewClientId',
|
||||
'getOrGenerateClientId',
|
||||
]);
|
||||
mockClientIdService.generateNewClientId.and.resolveTo('B_new1');
|
||||
mockClientIdService.getOrGenerateClientId.and.resolveTo('test-client-id-123');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: ClientIdService, useValue: mockClientIdService }],
|
||||
|
|
@ -44,4 +50,13 @@ describe('CLIENT_ID_PROVIDER', () => {
|
|||
|
||||
await expectAsync(provider.loadClientId()).toBeRejectedWith(error);
|
||||
});
|
||||
|
||||
it('should delegate getOrGenerateClientId to ClientIdService', async () => {
|
||||
mockClientIdService.getOrGenerateClientId.and.resolveTo('B_a7Kx');
|
||||
|
||||
const result = await provider.getOrGenerateClientId();
|
||||
|
||||
expect(result).toBe('B_a7Kx');
|
||||
expect(mockClientIdService.getOrGenerateClientId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ import { ClientIdService } from '../../core/util/client-id.service';
|
|||
*/
|
||||
export interface ClientIdProvider {
|
||||
loadClientId(): Promise<string | null>;
|
||||
/**
|
||||
* Returns the stored client ID, or generates and persists a new one if
|
||||
* none is stored or the stored value is invalid. Preferred over calling
|
||||
* loadClientId() with a manual fallback.
|
||||
*/
|
||||
getOrGenerateClientId(): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -27,7 +33,7 @@ export interface ClientIdProvider {
|
|||
* ```typescript
|
||||
* private clientIdProvider = inject(CLIENT_ID_PROVIDER);
|
||||
* // ...
|
||||
* const clientId = await this.clientIdProvider.loadClientId();
|
||||
* const clientId = await this.clientIdProvider.getOrGenerateClientId();
|
||||
* ```
|
||||
*/
|
||||
export const CLIENT_ID_PROVIDER = new InjectionToken<ClientIdProvider>(
|
||||
|
|
@ -38,6 +44,7 @@ export const CLIENT_ID_PROVIDER = new InjectionToken<ClientIdProvider>(
|
|||
const clientIdService = inject(ClientIdService);
|
||||
return {
|
||||
loadClientId: () => clientIdService.loadClientId(),
|
||||
getOrGenerateClientId: () => clientIdService.getOrGenerateClientId(),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1046,7 +1046,7 @@ export class PluginService implements OnDestroy {
|
|||
// Validate index.html size (same as manifest for now)
|
||||
if (indexHtmlBytes.length > MAX_PLUGIN_MANIFEST_SIZE) {
|
||||
throw new Error(
|
||||
this._translateService.instant(T.PLUGINS.INDEX_HTML_TOO_LARGE, {
|
||||
this._translateService.instant(T.PLUGINS.MANIFEST_TOO_LARGE, {
|
||||
maxSize: (MAX_PLUGIN_MANIFEST_SIZE / 1024).toFixed(1),
|
||||
fileSize: (indexHtmlBytes.length / 1024).toFixed(1),
|
||||
}),
|
||||
|
|
@ -1062,7 +1062,7 @@ export class PluginService implements OnDestroy {
|
|||
// Validate icon size (same as manifest for now)
|
||||
if (iconBytes.length > MAX_PLUGIN_MANIFEST_SIZE) {
|
||||
throw new Error(
|
||||
this._translateService.instant(T.PLUGINS.ICON_TOO_LARGE, {
|
||||
this._translateService.instant(T.PLUGINS.MANIFEST_TOO_LARGE, {
|
||||
maxSize: (MAX_PLUGIN_MANIFEST_SIZE / 1024).toFixed(1),
|
||||
fileSize: (iconBytes.length / 1024).toFixed(1),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1375,8 +1375,11 @@ const T = {
|
|||
ERROR_PERMISSION: 'F.SYNC.S.ERROR_PERMISSION',
|
||||
ERROR_PERMISSION_FLATPAK: 'F.SYNC.S.ERROR_PERMISSION_FLATPAK',
|
||||
ERROR_PERMISSION_SNAP: 'F.SYNC.S.ERROR_PERMISSION_SNAP',
|
||||
ERROR_REMOTE_FILE_CORRUPTED: 'F.SYNC.S.ERROR_REMOTE_FILE_CORRUPTED',
|
||||
ERROR_REMOTE_FILE_EMPTY: 'F.SYNC.S.ERROR_REMOTE_FILE_EMPTY',
|
||||
ERROR_REMOTE_FILE_LOCKED: 'F.SYNC.S.ERROR_REMOTE_FILE_LOCKED',
|
||||
ERROR_SYNC_VERSION_MISMATCH: 'F.SYNC.S.ERROR_SYNC_VERSION_MISMATCH',
|
||||
WARN_CLIENT_ID_REGENERATED: 'F.SYNC.S.WARN_CLIENT_ID_REGENERATED',
|
||||
ERROR_UNABLE_TO_READ_REMOTE_DATA: 'F.SYNC.S.ERROR_UNABLE_TO_READ_REMOTE_DATA',
|
||||
FINISH_DAY_SYNC_ERROR: 'F.SYNC.S.FINISH_DAY_SYNC_ERROR',
|
||||
FRESH_CLIENT_SYNC_CANCELLED: 'F.SYNC.S.FRESH_CLIENT_SYNC_CANCELLED',
|
||||
|
|
@ -1388,7 +1391,6 @@ const T = {
|
|||
INTEGRITY_CHECK_FAILED: 'F.SYNC.S.INTEGRITY_CHECK_FAILED',
|
||||
INVALID_AUTH_CODE: 'F.SYNC.S.INVALID_AUTH_CODE',
|
||||
INVALID_OPERATION_PAYLOAD: 'F.SYNC.S.INVALID_OPERATION_PAYLOAD',
|
||||
LEGACY_FORMAT_DETECTED: 'F.SYNC.S.LEGACY_FORMAT_DETECTED',
|
||||
LOCAL_CHANGES_DISCARDED: 'F.SYNC.S.LOCAL_CHANGES_DISCARDED',
|
||||
LOCAL_CHANGES_DISCARDED_SNAPSHOT: 'F.SYNC.S.LOCAL_CHANGES_DISCARDED_SNAPSHOT',
|
||||
LOCAL_DATA_REPLACE_CANCELLED: 'F.SYNC.S.LOCAL_DATA_REPLACE_CANCELLED',
|
||||
|
|
@ -2606,10 +2608,8 @@ const T = {
|
|||
GO_BACK: 'PLUGINS.GO_BACK',
|
||||
GRANT_PERMISSION: 'PLUGINS.GRANT_PERMISSION',
|
||||
HOOKS: 'PLUGINS.HOOKS',
|
||||
ICON_TOO_LARGE: 'PLUGINS.ICON_TOO_LARGE',
|
||||
ID: 'PLUGINS.ID',
|
||||
INDEX_HTML_NOT_LOADED: 'PLUGINS.INDEX_HTML_NOT_LOADED',
|
||||
INDEX_HTML_TOO_LARGE: 'PLUGINS.INDEX_HTML_TOO_LARGE',
|
||||
INSTALL_PLUGIN: 'PLUGINS.INSTALL_PLUGIN',
|
||||
INSTALL_WARNING: 'PLUGINS.INSTALL_WARNING',
|
||||
INSTALLING: 'PLUGINS.INSTALLING',
|
||||
|
|
|
|||
|
|
@ -18,13 +18,6 @@
|
|||
padding-top: var(--mac-title-bar-padding);
|
||||
}
|
||||
|
||||
// On iOS, the virtual keyboard overlaps position:fixed elements since the CDK
|
||||
// overlay container does not shrink with Capacitor's resize:'native' mode.
|
||||
// Subtract the keyboard height so Save/Close buttons stay above the keyboard.
|
||||
:host-context(body.isNativeMobile.isKeyboardVisible) {
|
||||
height: calc(100% - var(--keyboard-height, 0px));
|
||||
}
|
||||
|
||||
.formatting-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const base64UrlEncode = (buffer: Uint8Array): string => {
|
|||
* Uses crypto.subtle when available, otherwise falls back to hash-wasm.
|
||||
*/
|
||||
const sha256 = async (data: Uint8Array): Promise<ArrayBuffer> => {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle != null) {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle !== undefined) {
|
||||
return crypto.subtle.digest('SHA-256', data as BufferSource);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1337,8 +1337,11 @@
|
|||
"ERROR_PERMISSION": "File access denied. Please check your filesystem permissions.",
|
||||
"ERROR_PERMISSION_FLATPAK": "File access denied. Grant filesystem permission via Flatseal or use a path inside ~/.var/app/",
|
||||
"ERROR_PERMISSION_SNAP": "File access denied. Run 'snap connect super-productivity:home' or use a path inside ~/snap/super-productivity/common/",
|
||||
"ERROR_REMOTE_FILE_CORRUPTED": "Remote sync data is unreadable (possibly truncated or corrupted). Your local data is safe. You can overwrite the remote with your local data.",
|
||||
"ERROR_REMOTE_FILE_EMPTY": "Remote sync file is empty. This can happen after an interrupted sync. Overwrite remote with your local data?",
|
||||
"ERROR_REMOTE_FILE_LOCKED": "Remote sync file is locked by the server. This may resolve on the next sync attempt.",
|
||||
"ERROR_SYNC_VERSION_MISMATCH": "Remote sync data uses an incompatible format version. Ensure all devices run the same version of Super Productivity.",
|
||||
"WARN_CLIENT_ID_REGENERATED": "Sync device ID was invalid and has been regenerated. A sync conflict may occur on next sync.",
|
||||
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Error while syncing. Unable to read remote data. Maybe you enabled encryption and your local password does not match the one used to encrypt the remote data?",
|
||||
"FINISH_DAY_SYNC_ERROR": "Sync error while finishing day. Please try again.",
|
||||
"FRESH_CLIENT_SYNC_CANCELLED": "Initial sync cancelled. Remote data was not applied.",
|
||||
|
|
@ -1350,7 +1353,6 @@
|
|||
"INTEGRITY_CHECK_FAILED": "Data integrity issue detected. Auto-repair attempted. If you experience issues, please reload the app.",
|
||||
"INVALID_AUTH_CODE": "The authorization code was rejected. Please try authenticating again and make sure to copy the code exactly.",
|
||||
"INVALID_OPERATION_PAYLOAD": "Invalid operation detected. Changes may not be saved - please reload.",
|
||||
"LEGACY_FORMAT_DETECTED": "Sync format mismatch: the remote storage was last written by an older app version (v16.x or earlier) that uses a different sync format. Please update all your devices to the same app version.",
|
||||
"LOCAL_CHANGES_DISCARDED": "{{count}} local change(s) discarded - item(s) were deleted on another device",
|
||||
"LOCAL_CHANGES_DISCARDED_SNAPSHOT": "{{count}} unsaved local change(s) discarded - sync downloaded newer data",
|
||||
"LOCAL_DATA_REPLACE_CANCELLED": "Sync cancelled. Your local data has been preserved.",
|
||||
|
|
@ -2553,10 +2555,8 @@
|
|||
"GO_BACK": "Go Back",
|
||||
"GRANT_PERMISSION": "Grant Permission",
|
||||
"HOOKS": "Hooks",
|
||||
"ICON_TOO_LARGE": "Plugin icon is too large (max {{maxSize}}KB)",
|
||||
"ID": "ID:",
|
||||
"INDEX_HTML_NOT_LOADED": "Plugin index.html not loaded",
|
||||
"INDEX_HTML_TOO_LARGE": "Plugin index.html is too large (max {{maxSize}}KB)",
|
||||
"INSTALL_PLUGIN": "Install Plugin",
|
||||
"INSTALL_WARNING": "Before installing a plugin, ensure you trust its source and understand the permissions it requests.",
|
||||
"INSTALLING": "Installing...",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
"DONATE_PAGE": {
|
||||
"BUTTON_TEXT": "Wesprzyj przez GitHub Sponsors",
|
||||
"INTRO_1": "Super Productivity jest finansowany w całości przez społeczność. Nie ma śledzenia, reklam ani zbierania danych — Twoje zadania pozostają na Twoim urządzeniu.",
|
||||
"INTRO_2": "Jeśli cenisz takie podejście i chcesz, aby projekt był utrzymywany w dobrej kondycji i się rozwijał, dobrowolna darowizna jest bardzo mile widziana."
|
||||
"INTRO_2": "Jeśli cenisz takie podejście i chcesz, aby projekt był zdrowy i się rozwijał, dobrowolna darowizna jest bardzo mile widziana."
|
||||
},
|
||||
"EXAMPLE_TASKS": {
|
||||
"LEARN_KEYBOARD_SHORTCUTS": {
|
||||
|
|
@ -167,18 +167,8 @@
|
|||
"EVENT_STR": "Wydarzenie",
|
||||
"EVENTS_STR": "Wydarzenia"
|
||||
},
|
||||
"CONTEXT_MENU": {
|
||||
"CREATE_TASK": "Utwórz jako zadanie",
|
||||
"OPEN_IN_CALENDAR": "Otwórz w kalendarzu",
|
||||
"RESCHEDULE": "Reschedule",
|
||||
"DELETE_EVENT": "Usuń zdarzenie"
|
||||
},
|
||||
"S": {
|
||||
"CAL_PROVIDER_ERROR": "Błąd dostawcy kalendarza: {{errTxt}}",
|
||||
"EVENT_HIDDEN": "Calendar event hidden",
|
||||
"EVENT_RESCHEDULED": "Event rescheduled",
|
||||
"EVENT_DELETED": "Event deleted",
|
||||
"TIME_BLOCK_ERROR": "Failed to sync time block: {{errTxt}}"
|
||||
"CAL_PROVIDER_ERROR": "Błąd dostawcy kalendarza: {{errTxt}}"
|
||||
}
|
||||
},
|
||||
"CLIPBOARD_IMAGE": {
|
||||
|
|
@ -275,7 +265,7 @@
|
|||
},
|
||||
"GITEA": {
|
||||
"FORM": {
|
||||
"HOST": "Host (np. https://try.gitea.io)",
|
||||
"HOST": "Host (np.: https://try.gitea.io)",
|
||||
"REPO_FULL_NAME": "Nazwa użytkownika lub nazwa organizacji/projektu",
|
||||
"REPO_FULL_NAME_DESCRIPTION": "Można go znaleźć jako część adresu URL podczas przeglądania projektu w przeglądarce.",
|
||||
"SCOPE": "Zakres",
|
||||
|
|
@ -432,15 +422,7 @@
|
|||
"ADVANCED_CONFIG": "Zaawansowana konfiguracja",
|
||||
"CONNECTED": "Połączone",
|
||||
"DELETE_CONFIRM": "Czy na pewno chcesz usunąć tego dostawcę zgłoszeń? Usunięcie oznacza, że <strong>wszystkie wcześniej zaimportowane zadania zgłoszeń stracą swoje powiązanie</strong>. Tej operacji nie można cofnąć!",
|
||||
"DISCONNECT": "Rozłączone",
|
||||
"EDIT_TITLE": "Edit {{title}} Config",
|
||||
"LOAD_OPTIONS": "Load Options",
|
||||
"LOAD_OPTIONS_FAILED": "Failed to load options",
|
||||
"LOADING_OPTIONS": "Loading options...",
|
||||
"OPTIONS_LOADED": "Options loaded",
|
||||
"RELOAD_OPTIONS": "Reload Options",
|
||||
"SETUP_TITLE": "Setup {{title}} Config",
|
||||
"TEST_CONNECTION": "Test Connection"
|
||||
"DISCONNECT": "Rozłączone"
|
||||
}
|
||||
},
|
||||
"ISSUE_PANEL": {
|
||||
|
|
@ -512,7 +494,7 @@
|
|||
},
|
||||
"FORM_CRED": {
|
||||
"ALLOW_SELF_SIGNED": "Zezwalaj na certyfikat self-signed",
|
||||
"HOST": "Host (np. http://moja-domena.pl:1234)",
|
||||
"HOST": "Host (np.: http://mój-host.pl:1234)",
|
||||
"PASSWORD": "Token / hasło",
|
||||
"USE_PAT": "Zamiast tego użyj osobistego tokena dostępu (LEGACY)",
|
||||
"USER_NAME": "E-mail / nazwa użytkownika",
|
||||
|
|
@ -710,13 +692,13 @@
|
|||
"STRIKETHROUGH": "Przekreślenie",
|
||||
"TASK_LIST": "Lista zadań"
|
||||
},
|
||||
"VIEW_PARSED": "Wyświetl jako sparsowany (nieedytowalny) markdown",
|
||||
"VIEW_SPLIT": "Wyświetl jako sparsowany i niesformatowany Markdown w widoku podzielonym",
|
||||
"VIEW_TEXT_ONLY": "Wyświetl jako niesparsowany tekst"
|
||||
"VIEW_PARSED": "Wyświetlanie jako przeanalizowany (nieedytowalny) markdown",
|
||||
"VIEW_SPLIT": "Wyświetlanie przeanalizowanego i nieprzeanalizowanego markdown w widoku podzielonym",
|
||||
"VIEW_TEXT_ONLY": "Wyświetlanie jako nieprzeanalizowany tekst"
|
||||
},
|
||||
"NOTE_CMP": {
|
||||
"DISABLE_PARSE": "Wyłącz parsowanie Markdown przy podglądzie",
|
||||
"ENABLE_PARSE": "Włącz parsowanie Markdown"
|
||||
"DISABLE_PARSE": "Wyłącz markdown parsing",
|
||||
"ENABLE_PARSE": "Włącz markdown parse"
|
||||
},
|
||||
"NOTES_CMP": {
|
||||
"ADD_BTN": "Dodaj nową notatkę",
|
||||
|
|
@ -949,7 +931,7 @@
|
|||
},
|
||||
"END": "Koniec pracy",
|
||||
"INSERT_BEFORE": "Przed",
|
||||
"LUNCH_BREAK": "Przerwa na lunch",
|
||||
"LUNCH_BREAK": "Przerwa obiadowa",
|
||||
"MONTH": "Miesiąc",
|
||||
"NO_TASKS": "Obecnie nie ma żadnych zadań. Dodaj zadania za pomocą przycisku plus (+) na górnym pasku.",
|
||||
"NOW": "Teraz",
|
||||
|
|
@ -969,16 +951,16 @@
|
|||
"OK": "Zrób to!"
|
||||
},
|
||||
"D_EDIT": {
|
||||
"CURRENT_STREAK": "Obecna seria",
|
||||
"CURRENT_STREAK": "Aktualna passa",
|
||||
"DAILY_GOAL": "Cel dzienny",
|
||||
"DAYS": "Dni",
|
||||
"L_COUNTER": "Stan"
|
||||
"L_COUNTER": "Licznik"
|
||||
},
|
||||
"FORM": {
|
||||
"ADD_NEW": "Dodaj prosty licznik",
|
||||
"HELP": "Tutaj możesz skonfigurować proste przyciski, które pojawią się w prawym górnym rogu. Mogą to być timery albo zwykły licznik, który zwiększa się po kliknięciu.",
|
||||
"L_COUNTDOWN_DURATION": "Czas trwania odliczania",
|
||||
"L_DAILY_GOAL": "Dzienny cel utrzymania serii",
|
||||
"L_DAILY_GOAL": "Codzienny cel dla udanej passy",
|
||||
"L_ICON": "Ikona",
|
||||
"L_ICON_ON": "Ikona po włączeniu",
|
||||
"L_IS_ENABLED": "Włączone",
|
||||
|
|
@ -989,11 +971,11 @@
|
|||
"L_TITLE": "Tytuł",
|
||||
"L_TRACK_STREAKS": "Śledź serie",
|
||||
"L_TYPE": "Typ",
|
||||
"L_WEEKDAYS": "Dni sprawdzania serii",
|
||||
"L_WEEKLY_FREQUENCY": "Wymagana liczba ukończeń w tygodniu",
|
||||
"L_WEEKDAYS": "Dni powszednie, aby sprawdzić smugę",
|
||||
"L_WEEKLY_FREQUENCY": "Liczba wykonań na tydzień",
|
||||
"TITLE": "Proste liczniki",
|
||||
"TYPE_CLICK_COUNTER": "Licznik kliknięć",
|
||||
"TYPE_REPEATED_COUNTDOWN": "Codzienne odliczanie",
|
||||
"TYPE_REPEATED_COUNTDOWN": "Powtarzane odliczanie",
|
||||
"TYPE_STOPWATCH": "Stoper"
|
||||
},
|
||||
"HABIT_TRACKER": {
|
||||
|
|
@ -1005,7 +987,7 @@
|
|||
},
|
||||
"S": {
|
||||
"GOAL_REACHED_1": "Osiągnąłeś swój dzisiejszy cel!",
|
||||
"GOAL_REACHED_2": "Czas trwania obecnej serii:"
|
||||
"GOAL_REACHED_2": "Aktualny czas trwania passy:"
|
||||
}
|
||||
},
|
||||
"SYNC": {
|
||||
|
|
@ -1555,7 +1537,7 @@
|
|||
"QA_NEXT_WEEK": "Następny tydzień",
|
||||
"QA_TODAY": "Dzisiaj",
|
||||
"QA_TOMORROW": "Jutro",
|
||||
"REMIND_AT": "Przypomnij",
|
||||
"REMIND_AT": "Przypomnij o",
|
||||
"REMOVE_DEADLINE": "Usuń termin",
|
||||
"RO_1H": "1 godzinę przed",
|
||||
"RO_5M": "5 minut przed",
|
||||
|
|
@ -1595,7 +1577,7 @@
|
|||
"QA_NEXT_WEEK": "Harmonogram na następny tydzień",
|
||||
"QA_TODAY": "Harmonogram na dziś",
|
||||
"QA_TOMORROW": "Harmonogram na jutro",
|
||||
"REMIND_AT": "Przypomnij",
|
||||
"REMIND_AT": "Przypomnij o",
|
||||
"RO_1H": "1 godzinę przed rozpoczęciem",
|
||||
"RO_5M": "5 minut przed rozpoczęciem",
|
||||
"RO_10M": "10 minut przed rozpoczęciem",
|
||||
|
|
@ -1612,7 +1594,7 @@
|
|||
"TITLE": "Wybierz datę i godzinę"
|
||||
},
|
||||
"D_TIME": {
|
||||
"ADD_FOR_OTHER_DAY": "Dodaj czas spędzony innego dnia",
|
||||
"ADD_FOR_OTHER_DAY": "Dodaj czas spędzony dla innego dnia",
|
||||
"DELETE_FOR": "Usuń wpis dla dnia",
|
||||
"ESTIMATE": "Szacowanie",
|
||||
"TIME_SPENT": "Czas spędzony",
|
||||
|
|
@ -1622,7 +1604,7 @@
|
|||
"D_TIME_FOR_DAY": {
|
||||
"ADD_ENTRY_FOR": "Dodaj nowy wpis dla {{date}}",
|
||||
"DATE": "Data nowego wpisu",
|
||||
"HELP": "Przykłady:<br> 30m => 30 minut<br> 2h => 2 godziny<br> 2h30m => 2 godziny i 30 minut<br> 1.5h => 1 godzina i 30 minut<br> 03:15 => 3 godziny i 15 minut",
|
||||
"HELP": "Przykłady:<br> 30m => 30 minut<br> 2h => 2 godziny<br> 2h30m => 2 godziny i 30 minut<br> 1.5h => 1 godzina i 30 minut",
|
||||
"TINE_SPENT": "Czas spędzony",
|
||||
"TITLE": "Dodaj dla dnia"
|
||||
},
|
||||
|
|
@ -1946,7 +1928,7 @@
|
|||
"DO_IT": "Zrób to!",
|
||||
"DONT_SHOW_AGAIN": "Nie pokazuj ponownie",
|
||||
"DUPLICATE": "Powiel",
|
||||
"DURATION_DESCRIPTION": "np. \"5h 23m\" oznacza 5 godzin i 23 minuty",
|
||||
"DURATION_DESCRIPTION": "np. \"5h 23m\", oznacza 5 godzin i 23 minuty",
|
||||
"EDIT": "Edytuj",
|
||||
"ENABLED": "Włączone",
|
||||
"EXAMPLE_VAL": "np. 32m",
|
||||
|
|
@ -1988,7 +1970,7 @@
|
|||
"HELP": "Włącz lub wyłącz konkretne funkcje aplikacji w całym interfejsie użytkownika.",
|
||||
"ISSUES_PANEL": "Panel zgłoszeń",
|
||||
"PLANNER": "Planer",
|
||||
"PROJECT_NOTES": "Notatki projektu",
|
||||
"PROJECT_NOTES": "Notatki projektowe",
|
||||
"SCHEDULE": "Harmonogram",
|
||||
"SCHEDULE_DAY_PANEL": "Panel dnia harmonogramu",
|
||||
"SEARCH": "Szukaj",
|
||||
|
|
@ -1997,9 +1979,7 @@
|
|||
"TITLE": "Funkcje aplikacji",
|
||||
"USER_PROFILES": "Profile użytkowników (eksperymentalne)",
|
||||
"USER_PROFILES_HINT": "Pozwala tworzyć i przełączać się między różnymi profilami użytkowników, z których każdy ma oddzielne ustawienia, zadania i konfiguracje synchronizacji. Przycisk zarządzania profilem pojawi się w prawym górnym rogu po włączeniu. Uwaga: Wyłączenie tej funkcji ukryje interfejs użytkownika, ale zachowa dane profilu (Funkcja eksperymentalna, brak gwarancji. Upewnij się, że masz kopię zapasową danych!).",
|
||||
"USER_PROFILES_WARNING": "<strong>Funkcja eksperymentalna:</strong> Ta funkcja jest wciąż w fazie rozwoju i może zostać usunięta lub znacząco zmieniona w przyszłej aktualizacji. Używasz na własne ryzyko. Upewnij się, że masz kopię zapasową danych.",
|
||||
"EXPERIMENTAL_WARNING_TITLE": "Funkcja eksperymentalna",
|
||||
"EXPERIMENTAL_WARNING_MSG": "Ta funkcja jest wciąż w fazie rozwoju i może zostać usunięta lub znacząco zmieniona w przyszłej aktualizacji. Używasz na własne ryzyko. Upewnij się, że masz kopię zapasową danych.<br/><br/>Czy na pewno chcesz ją aktywować?"
|
||||
"USER_PROFILES_WARNING": "<strong>Funkcja eksperymentalna:</strong> Ta funkcja jest wciąż w fazie rozwoju i może zostać usunięta lub znacząco zmieniona w przyszłej aktualizacji. Używasz na własne ryzyko. Upewnij się, że masz kopię zapasową danych."
|
||||
},
|
||||
"AUTO_BACKUPS": {
|
||||
"HELP": "Automatycznie zapisuj wszystkie dane w folderze aplikacji, aby mieć kopię na wypadek problemów.",
|
||||
|
|
@ -2235,8 +2215,8 @@
|
|||
"HELP": "Funkcja harmonogramu ma dać Ci lepszy podgląd tego, jak zaplanowane zadania układają się w czasie. Ustawienia znajdziesz w sekcji „Harmonogram”.",
|
||||
"L_IS_LUNCH_BREAK_ENABLED": "Włącz przerwę na lunch",
|
||||
"L_IS_WORK_START_END_ENABLED": "Ogranicz przepływ niezaplanowanych zadań do określonych godzin pracy",
|
||||
"L_LUNCH_BREAK_END": "Koniec przerwy na lunch",
|
||||
"L_LUNCH_BREAK_START": "Początek przerwy na lunch",
|
||||
"L_LUNCH_BREAK_END": "Koniec przerwy obiadowej",
|
||||
"L_LUNCH_BREAK_START": "Początek przerwy obiadowej",
|
||||
"L_WORK_END": "Koniec dnia pracy",
|
||||
"L_WORK_START": "Początek dnia pracy",
|
||||
"LUNCH_BREAK_START_END_DESCRIPTION": "np. 13:00",
|
||||
|
|
@ -2382,14 +2362,14 @@
|
|||
"HABITS": "Nawyki",
|
||||
"HELP": "Pomoc",
|
||||
"HM": {
|
||||
"CALENDARS": "Poradnik: Łączenie się z kalendarzem",
|
||||
"CONTRIBUTE": "Współtwórz",
|
||||
"CALENDARS": "Jak to zrobić: Połącz kalendarze",
|
||||
"CONTRIBUTE": "Wkład",
|
||||
"GET_HELP_ONLINE": "Uzyskaj pomoc online",
|
||||
"KEYBOARD": "Poradnik: Zaawansowana klawiatura",
|
||||
"KEYBOARD": "Howto: Zaawansowana klawiatura",
|
||||
"REDDIT_COMMUNITY": "Społeczność Reddit",
|
||||
"REPORT_A_PROBLEM": "Zgłoś problem",
|
||||
"START_WELCOME": "Rozpocznij samouczek powitalny",
|
||||
"SYNC": "Poradnik: Konfiguracja synchronizacji"
|
||||
"START_WELCOME": "Rozpocznij wycieczkę powitalną",
|
||||
"SYNC": "Jak to zrobić: Konfiguracja synchronizacji"
|
||||
},
|
||||
"METRICS": "Metryki",
|
||||
"NO_PROJECT_INFO": "Brak dostępnych projektów. Możesz utworzyć nowy projekt, klikając przycisk 'Utwórz projekt'.",
|
||||
|
|
@ -2413,9 +2393,9 @@
|
|||
"TAGS": "Tagi",
|
||||
"TASK_LIST": "Lista zadań",
|
||||
"TASKS": "Zadania",
|
||||
"TOGGLE_SHOW_BOOKMARKS": "Pokaż/Ukryj zakładki",
|
||||
"TOGGLE_SHOW_BOOKMARKS": "Pokaż/ukryj zakładki",
|
||||
"TOGGLE_SHOW_ISSUE_PANEL": "Pokaż/Ukryj panel zgłoszeń",
|
||||
"TOGGLE_SHOW_NOTES": "Pokaż/Ukryj notatki projektu",
|
||||
"TOGGLE_SHOW_NOTES": "Pokaż/Ukryj Notatki Projektu",
|
||||
"TOGGLE_TRACK_TIME": "Start/Stop zliczania czasu",
|
||||
"TRIGGER_SYNC": "Ręczne wyzwalanie synchronizacji",
|
||||
"UNPLAN_ALL_TASKS": "Odplanowano wszystkie zadania",
|
||||
|
|
@ -2699,7 +2679,7 @@
|
|||
"NO_PLANNED_TASK_ALL_DONE": "wszystkie zadania zakończone",
|
||||
"NO_PLANNED_TASKS": "Nie zaplanowano zadań",
|
||||
"RECURRING_TASKS": "Zadania cykliczne",
|
||||
"RESET_BREAK_TIMER": "Zresetuj czas bez przerwy",
|
||||
"RESET_BREAK_TIMER": "Zresetuj bez czasu przerwy",
|
||||
"TIME_ESTIMATED": "Zaplanowany czas:",
|
||||
"TODAY_REMAINING": "Dziś pozostało:",
|
||||
"WITHOUT_BREAK": "Bez przerwy:",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"BY": "după",
|
||||
"NO_RESULTS": "Nicio imagine găsită. Te rog încearcă un alt termen de căutare.",
|
||||
"SEARCH_LABEL": "Caută imagini",
|
||||
"SEARCH_PLACEHOLDER": "d.e. natură, munți, abstract",
|
||||
"SEARCH_PLACEHOLDER": "ex: natură, munți, abstract",
|
||||
"TITLE": "Alege o imagine de fundal din Unsplash"
|
||||
},
|
||||
"DONATE_PAGE": {
|
||||
|
|
@ -94,8 +94,8 @@
|
|||
"ADD_NEW_PANEL": "Adaugă un nou panou",
|
||||
"BACKLOG_TASK_FILTER_ALL": "Toate",
|
||||
"BACKLOG_TASK_FILTER_NO_BACKLOG": "Exclude",
|
||||
"BACKLOG_TASK_FILTER_ONLY_BACKLOG": "Doar backlog",
|
||||
"BACKLOG_TASK_FILTER_TYPE": "Sarcini din backlog",
|
||||
"BACKLOG_TASK_FILTER_ONLY_BACKLOG": "Doar restante",
|
||||
"BACKLOG_TASK_FILTER_TYPE": "Sarcini restante",
|
||||
"COLUMNS": "Coloane",
|
||||
"TAGS_EXCLUDED": "Etichete excluse",
|
||||
"TAGS_REQUIRED": "Etichete obligatorii",
|
||||
|
|
@ -167,18 +167,8 @@
|
|||
"EVENT_STR": "eveniment",
|
||||
"EVENTS_STR": "evenimente"
|
||||
},
|
||||
"CONTEXT_MENU": {
|
||||
"CREATE_TASK": "Creează ca sarcină",
|
||||
"OPEN_IN_CALENDAR": "Deschide în calendar",
|
||||
"RESCHEDULE": "Replanifică",
|
||||
"DELETE_EVENT": "Șterge evenimentul"
|
||||
},
|
||||
"S": {
|
||||
"CAL_PROVIDER_ERROR": "Eroare furnizor calendar: {{errTxt}}",
|
||||
"EVENT_HIDDEN": "Eveniment calendar ascuns",
|
||||
"EVENT_RESCHEDULED": "Eveniment reprogramat",
|
||||
"EVENT_DELETED": "Eveniment șters",
|
||||
"TIME_BLOCK_ERROR": "Nu s-a putut sincroniza blocul de timp: {{errTxt}}"
|
||||
"CAL_PROVIDER_ERROR": "Eroare furnizor calendar: {{errTxt}}"
|
||||
}
|
||||
},
|
||||
"CLIPBOARD_IMAGE": {
|
||||
|
|
@ -275,7 +265,7 @@
|
|||
},
|
||||
"GITEA": {
|
||||
"FORM": {
|
||||
"HOST": "Gazdă (d.e. https://try.gitea.io)",
|
||||
"HOST": "Gazdă (ex: https://try.gitea.io)",
|
||||
"REPO_FULL_NAME": "Nume utilizator sau nume organizație/proiect",
|
||||
"REPO_FULL_NAME_DESCRIPTION": "Poate fi găsit ca parte a URL-ului cînd vizualizezi proiectul într-un navigator web.",
|
||||
"SCOPE": "Domeniu de aplicare",
|
||||
|
|
@ -313,7 +303,7 @@
|
|||
"FILTER_USER": "Filtrează după numele de utilizator",
|
||||
"GITLAB_BASE_URL": "URL de bază personalizat GitLab (opțional)",
|
||||
"PROJECT": "nume utilizator/proiect",
|
||||
"PROJECT_HINT": "d.e. super-productivity/super-productivity",
|
||||
"PROJECT_HINT": "ex: super-productivity/super-productivity",
|
||||
"SCOPE": "Domeniu de aplicare",
|
||||
"SCOPE_ALL": "Toate",
|
||||
"SCOPE_ASSIGNED": "Atribuite mie",
|
||||
|
|
@ -431,16 +421,8 @@
|
|||
"DIALOG": {
|
||||
"ADVANCED_CONFIG": "Configurare avansată",
|
||||
"CONNECTED": "Conectat",
|
||||
"DELETE_CONFIRM": "Dorești să ștergi acest furnizor de probleme? Ștergerea înseamnă că <strong>toate sarcinile de probleme importate anterior vor pierde referința</strong>. Aceasta acțiune nu poate fi anulată!",
|
||||
"DISCONNECT": "Deconectează",
|
||||
"EDIT_TITLE": "Editează configurația {{title}}",
|
||||
"LOAD_OPTIONS": "Încarcă opțiunile",
|
||||
"LOAD_OPTIONS_FAILED": "Nu s-au putut încărca opțiunile",
|
||||
"LOADING_OPTIONS": "Se încarcă opțiunile...",
|
||||
"OPTIONS_LOADED": "Opțiuni încărcate",
|
||||
"RELOAD_OPTIONS": "Reîncarcă opțiunile",
|
||||
"SETUP_TITLE": "Editează configurația {{title}}",
|
||||
"TEST_CONNECTION": "Testează conexiunea"
|
||||
"DELETE_CONFIRM": "Dorești să ștergi acest furnizor de probleme? Ștergerea înseamnă că <strong>toate sarcinile de probleme importate anterior vor pierde referința</strong>. Aceasta nu poate fi anulată!",
|
||||
"DISCONNECT": "Deconectează"
|
||||
}
|
||||
},
|
||||
"ISSUE_PANEL": {
|
||||
|
|
@ -450,37 +432,37 @@
|
|||
},
|
||||
"JIRA": {
|
||||
"BANNER": {
|
||||
"BLOCK_ACCESS_MSG": "Jira: Pentru a preveni blocarea API-ului, accesul a fost blocat de Super Productivity. Probabil ar trebui să verifici setările Jira!",
|
||||
"BLOCK_ACCESS_UNBLOCK": "Deblochează"
|
||||
"BLOCK_ACCESS_MSG": "Jira: To prevent API lock out, access has been blocked by Super Productivity. You probably should check your Jira settings!",
|
||||
"BLOCK_ACCESS_UNBLOCK": "Unblock"
|
||||
},
|
||||
"CFG_CMP": {
|
||||
"ALWAYS_ASK": "Deschide mereu dialogul",
|
||||
"DO_NOT": "Nu tranziționa",
|
||||
"DONE": "Stare pentru finalizarea sarcinii",
|
||||
"ENABLE": "Activează integrarea Jira",
|
||||
"ENABLE_TRANSITIONS": "Activează gestionarea tranzițiilor",
|
||||
"IN_PROGRESS": "Stare pentru începerea sarcinii",
|
||||
"LOAD_SUGGESTIONS": "Încarcă sugestiile",
|
||||
"MAP_CUSTOM_FIELDS": "Încarcă Story Points",
|
||||
"MAP_CUSTOM_FIELDS_INFO": "Din păcate unele din datele Jira sînt salvate sub cîmpuri particulare (custom fields) care sînt diferite pentru fiecare instalare. Pentru a include aceste date, trebuie să selectezi cîmpul personalizat adecvat. Momentan, există doar cîmpul pentru Story Points care trebuie asociat.",
|
||||
"OPEN": "Stare pentru punerea pe pauză a sarcinii",
|
||||
"SELECT_ISSUE_FOR_TRANSITIONS": "Selectează problema pentru a încărca tranzițiile disponibile",
|
||||
"STORY_POINTS": "Nume cîmp Story Points",
|
||||
"TRANSITION": "Gestionarea tranzițiilor"
|
||||
"ALWAYS_ASK": "Always open dialog",
|
||||
"DO_NOT": "Don't transition",
|
||||
"DONE": "Status for completing task",
|
||||
"ENABLE": "Enable Jira integration",
|
||||
"ENABLE_TRANSITIONS": "Enable Transition Handling",
|
||||
"IN_PROGRESS": "Status for starting task",
|
||||
"LOAD_SUGGESTIONS": "Load Suggestions",
|
||||
"MAP_CUSTOM_FIELDS": "Load Story Points",
|
||||
"MAP_CUSTOM_FIELDS_INFO": "Unfortunately some of Jira's data is saved under custom fields which are different for every installation. To include this data, you need to select the proper custom field for it. Currently there is only the story points field that needs to be mapped.",
|
||||
"OPEN": "Status for pausing task",
|
||||
"SELECT_ISSUE_FOR_TRANSITIONS": "Select issue to load available transitions",
|
||||
"STORY_POINTS": "Story Points Field Name",
|
||||
"TRANSITION": "Transition Handling"
|
||||
},
|
||||
"DIALOG_CONFIRM_ASSIGNMENT": {
|
||||
"MSG": "<strong>{{summary}}</strong> Este asociat curent cu <strong>{{assignee}}</strong>. Dorești să îl asociezi pentru tine?",
|
||||
"OK": "Fă-o!"
|
||||
"MSG": "<strong>{{summary}}</strong> is currently assigned to <strong>{{assignee}}</strong>. Do you want to assign it to yourself?",
|
||||
"OK": "Do it!"
|
||||
},
|
||||
"DIALOG_INITIAL": {
|
||||
"TITLE": "Configurează Jira pentru proiect"
|
||||
"TITLE": "Setup Jira for Project"
|
||||
},
|
||||
"DIALOG_TRANSITION": {
|
||||
"CHOOSE_STATUS": "Alege starea de atribuit",
|
||||
"CURRENT_ASSIGNEE": "Atribuit curent:",
|
||||
"CURRENT_STATUS": "Starea curentă:",
|
||||
"TASK_NAME": "Nume sarcină:",
|
||||
"TITLE": "Jira: Actualizează starea"
|
||||
"CHOOSE_STATUS": "Choose status to assign",
|
||||
"CURRENT_ASSIGNEE": "Current Assignee:",
|
||||
"CURRENT_STATUS": "Current Status:",
|
||||
"TASK_NAME": "Task name:",
|
||||
"TITLE": "Jira: Update Status"
|
||||
},
|
||||
"DIALOG_WORKLOG": {
|
||||
"CHECKBOXES": {
|
||||
|
|
@ -947,32 +929,32 @@
|
|||
"TEXT": "<p>Orarul îți oferă o viziune de ansamblu despre cum se desfășoară sarcinile planificate în timp. Este generat automat din sarcinile tale și necesită doar <strong>estimări de timp</strong> pentru a funcționa.</p><p>Două lucruri sînt distinse: <strong>Sarcini programate</strong>, care sînt afișate la ora planificată și <strong>Sarcini obișnuite</strong>, care ar trebui să curgă în jurul acestor evenimente fixe.</p><p>Dacă setezi o oră de început și sfîrșit a lucrului (recomandat), sarcinile obișnuite nu vor fi niciodată plasate în afara acestor limite.</p>",
|
||||
"TITLE": "Orar"
|
||||
},
|
||||
"END": "Finalul muncii",
|
||||
"INSERT_BEFORE": "Înainte",
|
||||
"LUNCH_BREAK": "Pauză de masă",
|
||||
"MONTH": "Lună",
|
||||
"NO_TASKS": "Momentan nu există sarcini. Adaugă cîteva sarcini prin butonul plus (+) din bara de sus.",
|
||||
"NOW": "Acum",
|
||||
"PLAN_END_DAY": "Final de {{date}}",
|
||||
"PLAN_START_DAY": "Început de {{date}}",
|
||||
"SHIFT_KEY_INFO": "Ține apăsat Shift pentru a comuta modul de planificare zilnică",
|
||||
"START": "Începerea muncii"
|
||||
"END": "Work End",
|
||||
"INSERT_BEFORE": "Before",
|
||||
"LUNCH_BREAK": "Lunch Break",
|
||||
"MONTH": "Month",
|
||||
"NO_TASKS": "Currently there are no tasks. Please add some tasks via the plus (+) button in the top bar.",
|
||||
"NOW": "Now",
|
||||
"PLAN_END_DAY": "End of {{date}}",
|
||||
"PLAN_START_DAY": "Start of {{date}}",
|
||||
"SHIFT_KEY_INFO": "Hold Shift to toggle day planning mode",
|
||||
"START": "Work Start"
|
||||
},
|
||||
"SEARCH_BAR": {
|
||||
"INFO": "Începe să tastezi pentru a căuta sarcini",
|
||||
"NO_RESULTS": "Nu s-au găsit sarcini care să se potrivească căutării",
|
||||
"PLACEHOLDER": "Caută sarcină sau notițe despre sarcină"
|
||||
"INFO": "Start typing to search for tasks",
|
||||
"NO_RESULTS": "No tasks found matching your search",
|
||||
"PLACEHOLDER": "Search for task or task notes"
|
||||
},
|
||||
"SIMPLE_COUNTER": {
|
||||
"D_CONFIRM_REMOVE": {
|
||||
"MSG": "Ștergerea unui contor simplu va șterge și toate datele urmărite pe el. Ești sigur că vrei să continui?",
|
||||
"OK": "Fă-o!"
|
||||
"MSG": "Deleting a simple counter, will also delete all past data tracked on it. Are you sure you want to proceed?",
|
||||
"OK": "Do it!"
|
||||
},
|
||||
"D_EDIT": {
|
||||
"CURRENT_STREAK": "Serie curentă",
|
||||
"DAILY_GOAL": "Obiectiv zilnic",
|
||||
"DAYS": "Zile",
|
||||
"L_COUNTER": "Număr"
|
||||
"CURRENT_STREAK": "Current Streak",
|
||||
"DAILY_GOAL": "Daily Goal",
|
||||
"DAYS": "Days",
|
||||
"L_COUNTER": "Count"
|
||||
},
|
||||
"FORM": {
|
||||
"ADD_NEW": "Adaugă un contor simplu sau un obicei",
|
||||
|
|
@ -985,7 +967,7 @@
|
|||
"L_IS_HIDE_BUTTON": "Ascunde butonul din bara de unelte",
|
||||
"L_STREAK_MODE": "Mod urmărire serie",
|
||||
"L_STREAK_MODE_SPECIFIC_DAYS": "Zilele săptămînii",
|
||||
"L_STREAK_MODE_WEEKLY_FREQUENCY": "Frecvență săptămînală (d.e. de 3 ori pe săptămînă)",
|
||||
"L_STREAK_MODE_WEEKLY_FREQUENCY": "Frecvență săptămînală (ex: de 3 ori pe săptămînă)",
|
||||
"L_TITLE": "Titlu",
|
||||
"L_TRACK_STREAKS": "Urmărește seriile",
|
||||
"L_TYPE": "Tip",
|
||||
|
|
@ -1018,40 +1000,40 @@
|
|||
"FORCE_UPLOAD": "Forțarea încărcării datelor poate duce la pierderea acestora. Ești sigur că vrei să continui?"
|
||||
},
|
||||
"D_AUTH_CODE": {
|
||||
"FOLLOW_LINK": "Te rog deschide următoarea legătură și copiază codul de autorizare furnizat acolo în cîmpul de intrare de mai jos.",
|
||||
"FOLLOW_LINK_MOBILE": "Te rog apasă butonul de mai jos pentru a autoriza. Vei fi redirecționat automat înapoi la aplicație.",
|
||||
"GET_AUTH_CODE": "Obține codul de autorizare",
|
||||
"L_AUTH_CODE": "Întrodu codul de autorizare",
|
||||
"TITLE": "Autentificare: {{provider}}",
|
||||
"WAITING_FOR_AUTH": "Se așteaptă autentificarea..."
|
||||
"FOLLOW_LINK": "Please open the following link and copy the auth code provided there into the input field below.",
|
||||
"FOLLOW_LINK_MOBILE": "Please tap the button below to authorize. You will be automatically redirected back to the app.",
|
||||
"GET_AUTH_CODE": "Get Authorization Code",
|
||||
"L_AUTH_CODE": "Enter Auth Code",
|
||||
"TITLE": "Login: {{provider}}",
|
||||
"WAITING_FOR_AUTH": "Waiting for authentication..."
|
||||
},
|
||||
"D_CONFLICT": {
|
||||
"ADDITIONAL_INFO": "Informații suplimentare",
|
||||
"CHANGES": "Modificări",
|
||||
"CHANGES_SINCE_LAST_SYNC": "Modificări de la ultima sincronizare",
|
||||
"COMPARISON_RESULT": "Rezultatul comparării",
|
||||
"DATE": "Dată",
|
||||
"LAST_SYNC": "Ultima Sincronizare:",
|
||||
"LAST_SYNCED": "Ultima sincronizare",
|
||||
"LAST_WRITE": "Ultima scriere",
|
||||
"ADDITIONAL_INFO": "Additional Info",
|
||||
"CHANGES": "Changes",
|
||||
"CHANGES_SINCE_LAST_SYNC": "Changes since last sync",
|
||||
"COMPARISON_RESULT": "Comparison Result",
|
||||
"DATE": "Date",
|
||||
"LAST_SYNC": "Last Sync:",
|
||||
"LAST_SYNCED": "Last Synced",
|
||||
"LAST_WRITE": "Last Write",
|
||||
"LOCAL": "Local",
|
||||
"LOCAL_REMOTE": "Local -> Distant",
|
||||
"NEVER": "Niciodată",
|
||||
"OVERWRITE_WARNING": "ATENȚIE: Datele {{targetName}} conțin aproximativ {{targetChanges}} modificări în timp ce datele {{sourceName}} au doar {{sourceChanges}} modificări. Ești sigur că vrei să înlocuiești datele {{targetName}} cu versiunea {{sourceName}}? Această acțiune nu poate fi anulată.",
|
||||
"REMOTE": "Distant",
|
||||
"RESULT": "Rezultat",
|
||||
"TEXT": "<p>Actualizează de la distanță. Atît datele locale cît și cele de pe server par modificate.</p>",
|
||||
"TIME": "Timp",
|
||||
"TIMESTAMP": "Marca temporală",
|
||||
"TITLE": "Sincronizare: Date conflictuale",
|
||||
"USE_LOCAL": "Păstrează local",
|
||||
"USE_REMOTE": "Păstrează distant",
|
||||
"VECTOR_CLOCK": "Ceas vectorial",
|
||||
"VECTOR_CLOCK_HEADING": "Ceas vectorial",
|
||||
"VECTOR_COMPARISON_CONCURRENT": "Concurent (conflict adevărat)",
|
||||
"VECTOR_COMPARISON_EQUAL": "Egal",
|
||||
"VECTOR_COMPARISON_LOCAL_GREATER": "Local > Distant",
|
||||
"VECTOR_COMPARISON_LOCAL_LESS": "Local < Distant"
|
||||
"LOCAL_REMOTE": "Local -> Remote",
|
||||
"NEVER": "Never",
|
||||
"OVERWRITE_WARNING": "WARNING: The {{targetName}} data contains approximately {{targetChanges}} changes while the {{sourceName}} data only has {{sourceChanges}} changes. Are you sure you want to overwrite the {{targetName}} data with the {{sourceName}} version? This action cannot be undone.",
|
||||
"REMOTE": "Remote",
|
||||
"RESULT": "Result",
|
||||
"TEXT": "<p>Update from remote. Both local and remote data seem to be modified.</p>",
|
||||
"TIME": "Time",
|
||||
"TIMESTAMP": "Timestamp",
|
||||
"TITLE": "Sync: Conflicting Data",
|
||||
"USE_LOCAL": "Keep local",
|
||||
"USE_REMOTE": "Keep remote",
|
||||
"VECTOR_CLOCK": "Vector Clock",
|
||||
"VECTOR_CLOCK_HEADING": "Vector Clock",
|
||||
"VECTOR_COMPARISON_CONCURRENT": "Concurrent (True Conflict)",
|
||||
"VECTOR_COMPARISON_EQUAL": "Equal",
|
||||
"VECTOR_COMPARISON_LOCAL_GREATER": "Local > Remote",
|
||||
"VECTOR_COMPARISON_LOCAL_LESS": "Local < Remote"
|
||||
},
|
||||
"D_DATA_REPAIR_CONFIRM": {
|
||||
"MSG": "Datele tale par deteriorate. Dorești să încerci repararea automată? Aceasta poate duce la pierderea minoră de date.",
|
||||
|
|
@ -1063,28 +1045,28 @@
|
|||
"UNKNOWN_ERROR": "Eroare necunoscută de sincronizare: {{err}}"
|
||||
},
|
||||
"D_DECRYPT_ERROR": {
|
||||
"BTN_OVER_WRITE_REMOTE": "Folosește această parolă și suprascrie-o pe cea de la distanță",
|
||||
"CHANGE_PW_AND_DECRYPT": "Reîncearcă decriptarea",
|
||||
"P1": "Datele tale de la distanță sînt criptate și decriptarea a eșuat.",
|
||||
"P2_FORGOT_PW": "Dacă ai <strong>uitat parola</strong>, întrodu parola corectă și apasă \"Reîncearcă decriptarea\".",
|
||||
"P3_PW_CHANGED": "Dacă ai <strong>schimbat parola pe un alt dispozitiv</strong>, întrodu noua parolă și apasă \"Suprascrie datele de la distanță\". Aceasta va încărca datele locale criptate cu această parolă pe server.",
|
||||
"PASSWORD": "Parolă",
|
||||
"TITLE": "Decriptarea a eșuat"
|
||||
"BTN_OVER_WRITE_REMOTE": "Use This Password & Overwrite Remote",
|
||||
"CHANGE_PW_AND_DECRYPT": "Retry Decrypt",
|
||||
"P1": "Your remote data is encrypted and decryption failed.",
|
||||
"P2_FORGOT_PW": "If you <strong>forgot your password</strong>, enter the correct password and click \"Retry Decrypt\".",
|
||||
"P3_PW_CHANGED": "If you <strong>changed your password on another device</strong>, enter your new password and click \"Overwrite Remote\". This will upload your local data to the server.",
|
||||
"PASSWORD": "Password",
|
||||
"TITLE": "Decryption Failed"
|
||||
},
|
||||
"D_ENTER_PASSWORD": {
|
||||
"BTN_FORCE_OVERWRITE": "Folosește datele locale",
|
||||
"BTN_SAVE_AND_SYNC": "Salvează și sincronizează",
|
||||
"FORCE_OVERWRITE_CONFIRM": "Acest lucru va <strong>șterge toate datele de pe server</strong> și va încărca datele locale criptate cu această parolă. Continui?",
|
||||
"FORCE_OVERWRITE_INFO": "Dacă preferi să păstrezi datele locale, poți suprascrie serverul în schimb.",
|
||||
"FORCE_OVERWRITE_TITLE": "Suprascrii datele de pe server?",
|
||||
"MESSAGE": "Serverul de sincronizare conține date criptate, dar nu este configurată nicio parolă de criptare pe acest dispozitiv. Te rog să întroduci parola de criptare pentru a decripta și sincroniza datele.",
|
||||
"PASSWORD_LABEL": "Parolă de criptare",
|
||||
"TITLE": "Parolă de criptare necesară"
|
||||
"BTN_FORCE_OVERWRITE": "Use Local Data",
|
||||
"BTN_SAVE_AND_SYNC": "Save & Sync",
|
||||
"FORCE_OVERWRITE_CONFIRM": "This will <strong>delete all data on the server</strong> and upload your local data encrypted with this password. Continue?",
|
||||
"FORCE_OVERWRITE_INFO": "If you prefer to keep your local data, you can overwrite the server instead.",
|
||||
"FORCE_OVERWRITE_TITLE": "Overwrite Server Data?",
|
||||
"MESSAGE": "The sync server contains encrypted data, but no encryption password is configured on this device. Please enter your encryption password to decrypt and sync your data.",
|
||||
"PASSWORD_LABEL": "Encryption Password",
|
||||
"TITLE": "Encryption Password Required"
|
||||
},
|
||||
"D_FRESH_CLIENT_CONFIRM": {
|
||||
"MESSAGE": "Aceasta pare să fie o instalare curată. Datele de la distanță cu {{count}} modificări au fost găsite. Dorești să descarci și să înlocuiești datele locale cu ele?",
|
||||
"OK": "Descarcă datele de la distanță",
|
||||
"TITLE": "Sincronizare inițială"
|
||||
"MESSAGE": "This appears to be a fresh installation. Remote data with {{count}} changes was found. Do you want to download and overwrite your local data with it?",
|
||||
"OK": "Download Remote Data",
|
||||
"TITLE": "Initial Sync"
|
||||
},
|
||||
"D_INCOMPLETE_SYNC": {
|
||||
"BTN_CLOSE_APP": "Închide aplicația",
|
||||
|
|
@ -1289,17 +1271,13 @@
|
|||
"SETUP_ENCRYPTION_PASSWORD_WARNING": "Folosește aceeași parolă pe toate dispozitivele. O parolă uitată poate fi resetată prin suprascrierea datelor de la distanță.",
|
||||
"SETUP_ENCRYPTION_TITLE": "SuperSync: Setare parolă"
|
||||
},
|
||||
"NEXTCLOUD": {
|
||||
"L_SERVER_URL": "Nextcloud Server URL",
|
||||
"SERVER_URL_DESCRIPTION": "e.g. https://cloud.example.com or https://example.com/nextcloud",
|
||||
"L_APP_PASSWORD": "App Password",
|
||||
"APP_PASSWORD_DESCRIPTION": "Go to Nextcloud → Settings → Security → App passwords to generate one"
|
||||
},
|
||||
"TITLE": "Sync",
|
||||
"WEB_DAV": {
|
||||
"CORS_INFO": "<strong>Making it work in a web browser:</strong> You need to allow Super Productivity to make CORS requests to your server. For Nextcloud, one approach is allowing \"https://app.super-productivity.com\" via the <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword</a> app. Refer to <a href='https://github.com/nextcloud/server/issues/3131'>this GitHub thread</a> for more information.",
|
||||
"CONDITIONAL_HEADER_WARNING_MSG": "Your WebDAV server does not support conditional headers (If-Unmodified-Since). This means Super Productivity cannot detect concurrent changes from other devices during upload.<br><br>Sync will still work, but there is a small risk of data conflicts if you make changes on multiple devices simultaneously.",
|
||||
"CONDITIONAL_HEADER_WARNING_TITLE": "Reduced Sync Safety",
|
||||
"CORS_INFO": "<strong>Making it work in a web browser:</strong> Allow Super Productivity to make CORS requests to your WebDAV server. This can have negative security implications! For Nextcloud, please refer to <a href='https://github.com/nextcloud/server/issues/3131'>this GitHub thread</a> for more information. One approach to make this work on mobile is allowing \"https://app.super-productivity.com\" via the Nextcloud app <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword<a>. Use at your own risk!</p>",
|
||||
"D_SYNC_FOLDER_PATH": "Path relative to the WebDAV server root where sync files will be stored (e.g. '/super-productivity' or '/'). This is NOT your server's internal directory path.",
|
||||
"INFO": "<strong>Warning:</strong> Generic WebDAV sync is provided <strong>as-is without support</strong>. If you are using Nextcloud, please use the Nextcloud sync option instead.",
|
||||
"INFO": "<strong>Warning:</strong> WebDAV implementations differ wildly. Super Productivity is known to work well with Nextcloud, <strong>but it may not work with your provider</strong>. Test thoroughly before relying on it.",
|
||||
"L_BASE_URL": "Base URL",
|
||||
"L_PASSWORD": "Password",
|
||||
"L_SYNC_FOLDER_PATH": "Sync Folder Path",
|
||||
|
|
@ -1337,8 +1315,6 @@
|
|||
"ERROR_PERMISSION": "File access denied. Please check your filesystem permissions.",
|
||||
"ERROR_PERMISSION_FLATPAK": "File access denied. Grant filesystem permission via Flatseal or use a path inside ~/.var/app/",
|
||||
"ERROR_PERMISSION_SNAP": "File access denied. Run 'snap connect super-productivity:home' or use a path inside ~/snap/super-productivity/common/",
|
||||
"ERROR_REMOTE_FILE_EMPTY": "Remote sync file is empty. This can happen after an interrupted sync. Overwrite remote with your local data?",
|
||||
"ERROR_REMOTE_FILE_LOCKED": "Remote sync file is locked by the server. This may resolve on the next sync attempt.",
|
||||
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Error while syncing. Unable to read remote data. Maybe you enabled encryption and your local password does not match the one used to encrypt the remote data?",
|
||||
"FINISH_DAY_SYNC_ERROR": "Sync error while finishing day. Please try again.",
|
||||
"FRESH_CLIENT_SYNC_CANCELLED": "Sincronizare inițială anulată. Datele de la distanță nu au fost aplicate.",
|
||||
|
|
@ -1429,11 +1405,11 @@
|
|||
"TITLE": "Basic Settings"
|
||||
},
|
||||
"S": {
|
||||
"ARCHIVE_CLEANUP_FAILED": "A eșuat curățarea datelor arhivate pentru eticheta ștearsă",
|
||||
"UPDATED": "Setările etichetei au fost actualizate"
|
||||
"ARCHIVE_CLEANUP_FAILED": "Curățarea datelor arhivate pentru eticheta ștearsă a eșuat",
|
||||
"UPDATED": "Tag settings were updated"
|
||||
},
|
||||
"TTL": {
|
||||
"ADD_NEW_TAG": "Adaugă etichetă nouă"
|
||||
"ADD_NEW_TAG": "Add new Tag"
|
||||
}
|
||||
},
|
||||
"TAG_FOLDER": {
|
||||
|
|
@ -1513,47 +1489,47 @@
|
|||
"ADD_SUB_TASK": "Adaugă subsarcină",
|
||||
"ADD_TO_MY_DAY": "Adaugă la lista de azi",
|
||||
"ADD_TO_PROJECT": "Adaugă la proiect",
|
||||
"CONVERT_TO_PARENT_TASK": "Convertește în sarcină părinte",
|
||||
"DELETE": "Șterge sarcina",
|
||||
"DELETE_REPEAT_INSTANCE": "Șterge instanța de sarcină recurentă",
|
||||
"DROP_ATTACHMENT": "Plasează aici pentru a atașa la \"{{title}}\"",
|
||||
"DUPLICATE": "Duplică",
|
||||
"CONVERT_TO_PARENT_TASK": "Convert to parent task",
|
||||
"DELETE": "Delete task",
|
||||
"DELETE_REPEAT_INSTANCE": "Delete recurring task instance",
|
||||
"DROP_ATTACHMENT": "Drop here to attach to \"{{title}}\"",
|
||||
"DUPLICATE": "Duplicate",
|
||||
"EDIT_DEADLINE": "Editează termen limită",
|
||||
"EDIT_SCHEDULED": "Planifică din nou",
|
||||
"FOCUS_SESSION": "Începe sesiune de concentrare",
|
||||
"MARK_DONE": "Marchează ca finalizată",
|
||||
"MOVE_TO_BACKLOG": "Mută în backlog",
|
||||
"MOVE_TO_OTHER_PROJECT": "Mută în proiect",
|
||||
"MOVE_TO_REGULAR": "Mută în lista obișnuită",
|
||||
"MOVE_TO_TOP": "Mută în partea de sus",
|
||||
"OPEN_ATTACH": "Atașează fișier sau legătură",
|
||||
"OPEN_ISSUE": "Deschide în navigatorul web",
|
||||
"OPEN_TIME": "Contorizare timp",
|
||||
"EDIT_SCHEDULED": "Reschedule",
|
||||
"FOCUS_SESSION": "Start focus session",
|
||||
"MARK_DONE": "Mark as completed",
|
||||
"MOVE_TO_BACKLOG": "Move to backlog",
|
||||
"MOVE_TO_OTHER_PROJECT": "Move to project",
|
||||
"MOVE_TO_REGULAR": "Move to regular list",
|
||||
"MOVE_TO_TOP": "Move to top",
|
||||
"OPEN_ATTACH": "Attach file or link",
|
||||
"OPEN_ISSUE": "Open in browser",
|
||||
"OPEN_TIME": "Time tracking",
|
||||
"REMOVE_DEADLINE": "Șterge termen limită",
|
||||
"REMOVE_FROM_MY_DAY": "Elimină din lista de azi",
|
||||
"SCHEDULE": "Planifică sarcina",
|
||||
"SCHEDULE": "Schedule task",
|
||||
"SET_DEADLINE": "Setează termen limită",
|
||||
"TOGGLE_ATTACHMENTS": "Arată/Ascunde atașamente",
|
||||
"TOGGLE_DETAIL_PANEL": "Arată/Ascunde informații suplimentare",
|
||||
"TOGGLE_DONE": "Comută starea de finalizare",
|
||||
"TOGGLE_SUB_TASK_VISIBILITY": "Comută vizibilitatea subsarcinilor",
|
||||
"TOGGLE_TAGS": "Comută etichetele",
|
||||
"TRACK_TIME": "Începe contorizarea timpului",
|
||||
"TRACK_TIME_STOP": "Pune pe pauză contorizarea timpului",
|
||||
"UNSCHEDULE_TASK": "Șterge planificarea sarcinii",
|
||||
"UPDATE_ISSUE_DATA": "Actualizează datele problemei"
|
||||
"TOGGLE_ATTACHMENTS": "Show/hide attachments",
|
||||
"TOGGLE_DETAIL_PANEL": "Show/hide additional info",
|
||||
"TOGGLE_DONE": "Toggle completion status",
|
||||
"TOGGLE_SUB_TASK_VISIBILITY": "Toggle subtask visibility",
|
||||
"TOGGLE_TAGS": "Toggle tags",
|
||||
"TRACK_TIME": "Start tracking time",
|
||||
"TRACK_TIME_STOP": "Pause tracking time",
|
||||
"UNSCHEDULE_TASK": "Unschedule task",
|
||||
"UPDATE_ISSUE_DATA": "Update issue data"
|
||||
},
|
||||
"D_CONFIRM_DELETE": {
|
||||
"MSG": "Dorești să ștergi sarcina \"{{title}}\"?",
|
||||
"OK": "Șterge"
|
||||
"OK": "Delete"
|
||||
},
|
||||
"D_CONFIRM_SHORT_SYNTAX_NEW_TAG": {
|
||||
"MSG": "DDorești să creezi eticheta nouă {{tagsTxt}}?",
|
||||
"OK": "Creează etichetă"
|
||||
"MSG": "Do you want to create the new tag {{tagsTxt}}?",
|
||||
"OK": "Create tag"
|
||||
},
|
||||
"D_CONFIRM_SHORT_SYNTAX_NEW_TAGS": {
|
||||
"MSG": "Dorești să creezi etichetele noi {{tagsTxt}}?",
|
||||
"OK": "Creează etichete"
|
||||
"MSG": "Do you want to create the new tags {{tagsTxt}}?",
|
||||
"OK": "Create tags"
|
||||
},
|
||||
"D_DEADLINE": {
|
||||
"ADD_TIME": "Adaugă ora",
|
||||
|
|
@ -1573,28 +1549,28 @@
|
|||
"SET_DEADLINE": "Setează termen limită"
|
||||
},
|
||||
"D_REMINDER_VIEW": {
|
||||
"ADD_ALL_TO_TODAY": "Adaugă-le pe toate la Astăzi",
|
||||
"ADD_TO_TODAY": "Adaugă la Astăzi",
|
||||
"ADD_ALL_TO_TODAY": "Add all to Today",
|
||||
"ADD_TO_TODAY": "Add to Today",
|
||||
"CLEAR_ALL_REMINDERS": "Șterge toate memento-urile",
|
||||
"CLEAR_REMINDER": "Șterge memento",
|
||||
"COMPLETE": "Finalizează",
|
||||
"COMPLETE_ALL": "Finalizează-le pe toate",
|
||||
"COMPLETE": "Complete",
|
||||
"COMPLETE_ALL": "Complete all",
|
||||
"DEADLINE_REMINDER": "Memento termen limită",
|
||||
"DEADLINE_SECTION": "Termene limită",
|
||||
"DISMISS_ALL_REMINDERS_KEEP_TODAY": "Înlătură toate mementourile (păstrează în Astăzi)",
|
||||
"DISMISS_REMINDER_KEEP_TODAY": "Înlătură memento (păstrează în Astăzi)",
|
||||
"DONE": "Realizat",
|
||||
"DISMISS_ALL_REMINDERS_KEEP_TODAY": "Dismiss All Reminders (Keep in Today)",
|
||||
"DISMISS_REMINDER_KEEP_TODAY": "Dismiss Reminder (Keep in Today)",
|
||||
"DONE": "Done",
|
||||
"DUE_SECTION": "Planificate",
|
||||
"DUE_TASK": "Memento sarcină planificată",
|
||||
"DUE_TASKS": "Memento sarcini planificate",
|
||||
"RESCHEDULE_EDIT": "Editează (planifică din nou)",
|
||||
"RESCHEDULE_UNTIL_TOMORROW": "Planifică din nou pentru mîine",
|
||||
"SNOOZE": "Amînă",
|
||||
"SNOOZE_ALL": "Amînă-le pe toate",
|
||||
"START": "Începe",
|
||||
"TASK_REMINDERS": "Mementouri sarcini",
|
||||
"UNSCHEDULE": "Șterge planificarea",
|
||||
"UNSCHEDULE_ALL": "Șterge planificarea tuturor"
|
||||
"DUE_TASK": "Reminder for planned task",
|
||||
"DUE_TASKS": "Reminder for planned tasks",
|
||||
"RESCHEDULE_EDIT": "Edit (reschedule)",
|
||||
"RESCHEDULE_UNTIL_TOMORROW": "Reschedule for tomorrow",
|
||||
"SNOOZE": "Snooze",
|
||||
"SNOOZE_ALL": "Snooze all",
|
||||
"START": "Start",
|
||||
"TASK_REMINDERS": "Memento-uri sarcini",
|
||||
"UNSCHEDULE": "Unschedule",
|
||||
"UNSCHEDULE_ALL": "Unschedule all"
|
||||
},
|
||||
"D_SCHEDULE_TASK": {
|
||||
"QA_NEXT_MONTH": "Schedule next month",
|
||||
|
|
@ -1613,9 +1589,9 @@
|
|||
"UNSCHEDULE": "Unschedule"
|
||||
},
|
||||
"D_SELECT_DATE_AND_TIME": {
|
||||
"DATE": "Dată",
|
||||
"TIME": "Oră",
|
||||
"TITLE": "Alege data și ora"
|
||||
"DATE": "Date",
|
||||
"TIME": "Time",
|
||||
"TITLE": "Select Date and Time"
|
||||
},
|
||||
"D_TIME": {
|
||||
"ADD_FOR_OTHER_DAY": "Add time spent for other day",
|
||||
|
|
@ -1695,18 +1671,18 @@
|
|||
"OK": "Remove completely"
|
||||
},
|
||||
"D_CONFIRM_UPDATE_INSTANCES": {
|
||||
"CANCEL": "Doar sarcinile viitoare",
|
||||
"MSG": "Există {{tasksNr}} instanțe create pentru această sarcină recurentă. Dorești să le actualizezi pe toate cu noile valori implicite sau doar să actualizezi sarcinile viitoare?",
|
||||
"OK": "Actualizează toate instanțele"
|
||||
"CANCEL": "Only future tasks",
|
||||
"MSG": "There are {{tasksNr}} instances created for this recurring task. Do you want to update all of them with the new defaults or just future tasks?",
|
||||
"OK": "Update all instances"
|
||||
},
|
||||
"D_SKIP_INSTANCE": {
|
||||
"MSG": "Omiți sarcina recurentă pe {{date}}? Acest lucru va preveni crearea sarcinii doar pe această dată.",
|
||||
"MSG": "Skip the recurring task on {{date}}? This will prevent the task from being created on this date only.",
|
||||
"OK": "Omite"
|
||||
},
|
||||
"D_EDIT": {
|
||||
"ADD": "Adaugă configurație sarcină recurentă",
|
||||
"ADVANCED_CFG": "Configurare avansată",
|
||||
"EDIT": "Editează configurația sarcinii recurente",
|
||||
"ADD": "Add Recurring Task Config",
|
||||
"ADVANCED_CFG": "Advanced configuration",
|
||||
"EDIT": "Edit Recurring Task Config",
|
||||
"HEATMAP_LABEL": "Activitate",
|
||||
"TAG_LABEL": "Etichete",
|
||||
"TIME_SPENT_THIS_MONTH": "Luna aceasta",
|
||||
|
|
@ -1714,105 +1690,105 @@
|
|||
"TIME_SPENT_TOTAL": "Total"
|
||||
},
|
||||
"F": {
|
||||
"C_DAY": "Zi",
|
||||
"C_MONTH": "Lună",
|
||||
"C_WEEK": "Săptămînă",
|
||||
"C_YEAR": "An",
|
||||
"DEFAULT_ESTIMATE": "Estimare implicită",
|
||||
"DISABLE_AUTO_UPDATE_SUBTASKS": "Dezactivează auto-actualizarea subsarcinilor",
|
||||
"DISABLE_AUTO_UPDATE_SUBTASKS_DESCRIPTION": "Nu actualiza automat subsarcinile moștenate cînd instanța cea mai nouă se modifică",
|
||||
"FRIDAY": "vineri",
|
||||
"INHERIT_SUBTASKS": "Moștenește subsarcini",
|
||||
"INHERIT_SUBTASKS_DESCRIPTION": "Cînd este activat, subsarcinile din instanța cea mai recentă a sarcinii vor fi recreate cu sarcina recurentă",
|
||||
"MONDAY": "luni",
|
||||
"NOTES": "Notițe implicite",
|
||||
"Q_CUSTOM": "Configurare recurentă personalizată",
|
||||
"Q_DAILY": "În fiecare zi",
|
||||
"Q_MONDAY_TO_FRIDAY": "În fiecare zi de luni pînă vineri",
|
||||
"Q_MONTHLY_CURRENT_DATE": "În fiecare lună în ziua {{dateDayStr}}",
|
||||
"C_DAY": "Day",
|
||||
"C_MONTH": "Month",
|
||||
"C_WEEK": "Week",
|
||||
"C_YEAR": "Year",
|
||||
"DEFAULT_ESTIMATE": "Default Estimate",
|
||||
"DISABLE_AUTO_UPDATE_SUBTASKS": "Disable auto-updating subtasks",
|
||||
"DISABLE_AUTO_UPDATE_SUBTASKS_DESCRIPTION": "Do not update inherited subtasks automatically when the newest instance changes",
|
||||
"FRIDAY": "Friday",
|
||||
"INHERIT_SUBTASKS": "Inherit subtasks",
|
||||
"INHERIT_SUBTASKS_DESCRIPTION": "When enabled, subtasks from the most recent task instance will be recreated with the recurring task",
|
||||
"MONDAY": "Monday",
|
||||
"NOTES": "Default notes",
|
||||
"Q_CUSTOM": "Custom recurring config",
|
||||
"Q_DAILY": "Every day",
|
||||
"Q_MONDAY_TO_FRIDAY": "Every Monday through Friday",
|
||||
"Q_MONTHLY_CURRENT_DATE": "Every month on the day {{dateDayStr}}",
|
||||
"Q_MONTHLY_FIRST_DAY": "În fiecare lună în prima zi",
|
||||
"Q_MONTHLY_LAST_DAY": "În fiecare lună în ultima zi",
|
||||
"Q_WEEKLY_CURRENT_WEEKDAY": "În fiecare săptămînă {{weekdayStr}}",
|
||||
"Q_YEARLY_CURRENT_DATE": "În fiecare an pe {{dayAndMonthStr}}",
|
||||
"QUICK_SETTING": "Configurație recurentă",
|
||||
"REMIND_AT": "Amintește la",
|
||||
"REMIND_AT_PLACEHOLDER": "Selectează cînd să fie amintit",
|
||||
"SKIP_FOR_DATE": "Omite pentru {{date}}",
|
||||
"SKIP_INSTANCE": "Omite pentru astăzi",
|
||||
"REPEAT_CYCLE": "Ciclu de repetare",
|
||||
"REPEAT_EVERY": "Repetă la fiecare",
|
||||
"SATURDAY": "sîmbătă",
|
||||
"SCHEDULE_TYPE_LABEL": "Tip programare",
|
||||
"Q_WEEKLY_CURRENT_WEEKDAY": "Every week on {{weekdayStr}}",
|
||||
"Q_YEARLY_CURRENT_DATE": "Every year on the {{dayAndMonthStr}}",
|
||||
"QUICK_SETTING": "Recurring Config",
|
||||
"REMIND_AT": "Remind at",
|
||||
"REMIND_AT_PLACEHOLDER": "Select when to remind",
|
||||
"SKIP_FOR_DATE": "Skip for {{date}}",
|
||||
"SKIP_INSTANCE": "Skip for today",
|
||||
"REPEAT_CYCLE": "Recur cycle",
|
||||
"REPEAT_EVERY": "Recur every",
|
||||
"SATURDAY": "Saturday",
|
||||
"SCHEDULE_TYPE_LABEL": "Schedule type",
|
||||
"SKIP_OVERDUE": "Omite instanțele întîrziate",
|
||||
"SKIP_OVERDUE_DESCRIPTION": "Cînd este activat, dacă aplicația este deschisă după ce a fost omisă o apariție programată (de ex. o sarcină săptămînală în care azi nu este o zi programată), instanța ratată va fi omisă silențios în loc să fie creată ca sarcină întîrziată",
|
||||
"START_DATE": "Dată de început",
|
||||
"START_TIME": "Timpul programat de început",
|
||||
"START_TIME_DESCRIPTION": "De ex. 15:00. Lasă necompletat pentru o sarcină pe toată ziua",
|
||||
"SUNDAY": "duminică",
|
||||
"THURSDAY": "joi",
|
||||
"TITLE": "Nume sarcină",
|
||||
"TUESDAY": "marți",
|
||||
"WEDNESDAY": "miercuri"
|
||||
"START_DATE": "Start date",
|
||||
"START_TIME": "Scheduled start time",
|
||||
"START_TIME_DESCRIPTION": "E.g. 15:00. Leave blank for an all day task",
|
||||
"SUNDAY": "Sunday",
|
||||
"THURSDAY": "Thursday",
|
||||
"TITLE": "Task title",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday"
|
||||
},
|
||||
"SNACK_REPEAT_DIALOG_FAIL": "Nu s-a putut deschide dialogul de configurare repetare. Puteți configura din meniul sarcinii."
|
||||
},
|
||||
"TASK_VIEW": {
|
||||
"CUSTOMIZER": {
|
||||
"DEADLINE_NEXT_MONTH": "Scadente luna viitoare",
|
||||
"DEADLINE_NEXT_WEEK": "Scadente săptămîna viitoare",
|
||||
"DEADLINE_THIS_MONTH": "Scadente luna aceasta",
|
||||
"DEADLINE_THIS_WEEK": "Scadente săptămîna aceasta",
|
||||
"DEADLINE_TODAY": "Scadente astăzi",
|
||||
"DEADLINE_TOMORROW": "Scadente mîine",
|
||||
"ENTER_PROJECT": "Filtrează proiecte",
|
||||
"ENTER_TAG": "Filtrează etichete",
|
||||
"ESTIMATED_TIME": "Timp estimat",
|
||||
"FILTER_BY": "Filtrează după",
|
||||
"FILTER_DEADLINE": "Scadență",
|
||||
"FILTER_DEFAULT": "Fără filtru",
|
||||
"FILTER_ESTIMATED_TIME": "Timp estimat",
|
||||
"FILTER_NOT_SPECIFIED": "Nu este specificat",
|
||||
"FILTER_PROJECT": "Proiect",
|
||||
"FILTER_SCHEDULED_DATE": "Dată programată",
|
||||
"FILTER_TAG": "Etichetă",
|
||||
"FILTER_TIME_SPENT": "Timp petrecut",
|
||||
"GROUP_BY": "Grupare după",
|
||||
"GROUP_DEADLINE": "Scadență",
|
||||
"GROUP_DEADLINE_NONE": "Fără scadență",
|
||||
"GROUP_DEFAULT": "Fără grup",
|
||||
"GROUP_PROJECT": "Proiect",
|
||||
"GROUP_SCHEDULED_DATE": "Dată programată",
|
||||
"GROUP_TAG": "Etichetă",
|
||||
"RESET_ALL": "Resetează tot",
|
||||
"DEADLINE_NEXT_MONTH": "Due Next Month",
|
||||
"DEADLINE_NEXT_WEEK": "Due Next Week",
|
||||
"DEADLINE_THIS_MONTH": "Due This Month",
|
||||
"DEADLINE_THIS_WEEK": "Due This Week",
|
||||
"DEADLINE_TODAY": "Due Today",
|
||||
"DEADLINE_TOMORROW": "Due Tomorrow",
|
||||
"ENTER_PROJECT": "Filter Projects",
|
||||
"ENTER_TAG": "Filter Tag",
|
||||
"ESTIMATED_TIME": "Estimated Time",
|
||||
"FILTER_BY": "Filter By",
|
||||
"FILTER_DEADLINE": "Deadline",
|
||||
"FILTER_DEFAULT": "No Filter",
|
||||
"FILTER_ESTIMATED_TIME": "Estimated Time",
|
||||
"FILTER_NOT_SPECIFIED": "Not specified",
|
||||
"FILTER_PROJECT": "Project",
|
||||
"FILTER_SCHEDULED_DATE": "Scheduled Date",
|
||||
"FILTER_TAG": "Tag",
|
||||
"FILTER_TIME_SPENT": "Time Spent",
|
||||
"GROUP_BY": "Group By",
|
||||
"GROUP_DEADLINE": "Deadline",
|
||||
"GROUP_DEADLINE_NONE": "No deadline",
|
||||
"GROUP_DEFAULT": "No Group",
|
||||
"GROUP_PROJECT": "Project",
|
||||
"GROUP_SCHEDULED_DATE": "Scheduled Date",
|
||||
"GROUP_TAG": "Tag",
|
||||
"RESET_ALL": "Reset All",
|
||||
"SAVE_SORT": "Salvează ca implicit",
|
||||
"SCHEDULED_NEXT_MONTH": "Luna viitoare",
|
||||
"SCHEDULED_NEXT_WEEK": "Săptămîna viitoare",
|
||||
"SCHEDULED_THIS_MONTH": "Luna aceasta",
|
||||
"SCHEDULED_THIS_WEEK": "Săptămîna aceasta",
|
||||
"SCHEDULED_NEXT_MONTH": "Next Month",
|
||||
"SCHEDULED_NEXT_WEEK": "Next Week",
|
||||
"SCHEDULED_THIS_MONTH": "This Month",
|
||||
"SCHEDULED_THIS_WEEK": "This Week",
|
||||
"SCHEDULED_TODAY": "Astăzi",
|
||||
"SCHEDULED_TOMORROW": "Mîine",
|
||||
"SORT": "Sortare",
|
||||
"SORT_CREATION_DATE": "Dată de creare",
|
||||
"SORT_DEADLINE": "Scadență",
|
||||
"SORT_DEFAULT": "Implicit",
|
||||
"SORT_NAME": "Nume",
|
||||
"SORT_SCHEDULED_DATE": "Dată programată",
|
||||
"TIME_1HOUR": "> 1 oră",
|
||||
"TIME_2HOUR": "> 2 ore",
|
||||
"TIME_10MIN": "> 10 minute",
|
||||
"TIME_30MIN": "> 30 minute",
|
||||
"TIME_SPENT": "Timp petrecut"
|
||||
"SORT_CREATION_DATE": "Creation Date",
|
||||
"SORT_DEADLINE": "Deadline",
|
||||
"SORT_DEFAULT": "Default",
|
||||
"SORT_NAME": "Name",
|
||||
"SORT_SCHEDULED_DATE": "Scheduled Date",
|
||||
"TIME_1HOUR": "> 1 Hour",
|
||||
"TIME_2HOUR": "> 2 Hours",
|
||||
"TIME_10MIN": "> 10 Minutes",
|
||||
"TIME_30MIN": "> 30 Minutes",
|
||||
"TIME_SPENT": "Time Spent"
|
||||
}
|
||||
},
|
||||
"TIME_TRACKING": {
|
||||
"B": {
|
||||
"ALREADY_DID": "Deja am făcut",
|
||||
"SNOOZE": "Amînă {{time}}"
|
||||
"ALREADY_DID": "I already did",
|
||||
"SNOOZE": "Snooze {{time}}"
|
||||
},
|
||||
"B_TTR": {
|
||||
"ADD_TO_TASK": "Adaugă la sarcină",
|
||||
"MSG": "Nu ai ținut evidența timpului pentru {{time}}",
|
||||
"MSG_WITHOUT_TIME": "Nu ai ținut evidența timpului"
|
||||
"ADD_TO_TASK": "Add to Task",
|
||||
"MSG": "You have not been tracking time for {{time}}",
|
||||
"MSG_WITHOUT_TIME": "You have not been tracking time"
|
||||
},
|
||||
"D_ARCHIVE_COMPRESS": {
|
||||
"BTN_COMPRESS": "Comprimă arhiva",
|
||||
|
|
@ -1952,10 +1928,10 @@
|
|||
"DO_IT": "Fă-l!",
|
||||
"DONT_SHOW_AGAIN": "Nu mai afișa din nou",
|
||||
"DUPLICATE": "Duplică",
|
||||
"DURATION_DESCRIPTION": "d.e. \"5h 23m\" care rezultă în 5 ore și 23 minute",
|
||||
"DURATION_DESCRIPTION": "ex: \"5h 23m\" care rezultă în 5 ore și 23 minute",
|
||||
"EDIT": "Editează",
|
||||
"ENABLED": "Activat",
|
||||
"EXAMPLE_VAL": "d.e. 32m",
|
||||
"EXAMPLE_VAL": "ex: 32m",
|
||||
"EXTENSION_INFO": "Te rog <a target=\"_blank\" href=\"https://chrome.google.com/webstore/detail/super-productivity/ljkbjodfmekklcoibdnhahlaalhihmlb\"> descarcă extensia Chrome</a> pentru a permite comunicarea cu API-ul Jira și tratarea timpului mort. Acest lucru nu funcționează pe mobil. <strong>Fără extensie, aceste funcții nu vor funcționa!</strong>",
|
||||
"HIDE": "Ascunde",
|
||||
"ICON_INP_DESCRIPTION": "Toate emoji-urile UTF-8 sînt de asemenea suportate!",
|
||||
|
|
@ -1966,7 +1942,7 @@
|
|||
"NEXT": "Următorul",
|
||||
"NO_CON": "Momentan ești offline. Te rog reconectează-te la internet.",
|
||||
"NONE": "Niciunul",
|
||||
"OK": "OK",
|
||||
"OK": "Ok",
|
||||
"OPEN_IN_BROWSER": "Deschide în navigatorul web",
|
||||
"OVERDUE": "Restante",
|
||||
"PASSWORD_STRENGTH_FAIR": "Acceptabilă",
|
||||
|
|
@ -1986,27 +1962,25 @@
|
|||
},
|
||||
"GCF": {
|
||||
"APP_FEATURES": {
|
||||
"BOARDS": "Table",
|
||||
"DONATE_PAGE": "Pagină de donații",
|
||||
"BOARDS": "Boards",
|
||||
"DONATE_PAGE": "Donate page",
|
||||
"FINISH_DAY": "Încheie ziua",
|
||||
"FOCUS_MODE": "Mod concentrare",
|
||||
"FOCUS_MODE": "Focus mode",
|
||||
"HABITS": "Obiceiuri",
|
||||
"HELP": "Activează sau dezactivează anumite funcții ale aplicației din interfață.",
|
||||
"ISSUES_PANEL": "Panou probleme",
|
||||
"PLANNER": "Planificator",
|
||||
"PROJECT_NOTES": "Notițe proiect",
|
||||
"HELP": "Enable or disable specific app features across the UI.",
|
||||
"ISSUES_PANEL": "Issues panel",
|
||||
"PLANNER": "Planner",
|
||||
"PROJECT_NOTES": "Project notes",
|
||||
"SCHEDULE": "Orar",
|
||||
"SCHEDULE_DAY_PANEL": "Panou programare zi",
|
||||
"SCHEDULE_DAY_PANEL": "Schedule day panel",
|
||||
"SEARCH": "Caută",
|
||||
"SYNC_BUTTON": "Buton sincronizare",
|
||||
"TIME_TRACKING": "Cronometru urmărire timp",
|
||||
"TITLE": "Funcții aplicație",
|
||||
"USER_PROFILES": "Profiluri utilizator (experimental — ar putea fi eliminate)",
|
||||
"USER_PROFILES_HINT": "Îți permite să creezi și să comuti între diferite profiluri de utilizator, fiecare cu setări, sarcini și configurații de sincronizare separate. Butonul de gestionare a profilurilor va apărea în colțul din dreapta sus cînd este activat. Notă: Dezactivarea acestei funcții va ascunde interfața dar va păstra datele profilului. (Funcție experimentală. Fără garanții. Asigură-te că ai o copie de rezervă!)",
|
||||
"USER_PROFILES_WARNING": "<strong>Funcție experimentală:</strong> Această funcție este încă în dezvoltare și ar putea fi eliminată sau modificată semnificativ într-o actualizare viitoare. Folosește cu grijă și asigură-te că ai copii de rezervă ale datelor tale.",
|
||||
"EXPERIMENTAL_WARNING_TITLE": "Feature Experimental",
|
||||
"EXPERIMENTAL_WARNING_MSG": "Această funcție este experimentală. Poate conține erori, poate fi modificată semnificativ sau poate fi eliminată într-o actualizare viitoare. Asigură-te că ai copii de rezervă ale datelor tale.<br/><br/>Vrei să o activezi?"
|
||||
},
|
||||
"SYNC_BUTTON": "Sync button",
|
||||
"TIME_TRACKING": "Stopwatch time tracking",
|
||||
"TITLE": "App Features",
|
||||
"USER_PROFILES": "User profiles (experimental)",
|
||||
"USER_PROFILES_HINT": "Allows you to create and switch between different user profiles, each with separate settings, tasks, and sync configurations. The profile management button will appear in the top-right corner when enabled. Note: Disabling this feature will hide the UI but preserve your profile data. (Experimental feature. No guarantees. Make sure to have a backup!)",
|
||||
"USER_PROFILES_WARNING": "<strong>Funcție experimentală:</strong> Această funcție este încă în dezvoltare și poate fi eliminată sau modificată semnificativ într-o actualizare viitoare. Folosește pe propriul risc și asigură-te că ai backup-uri ale datelor."
|
||||
},
|
||||
"AUTO_BACKUPS": {
|
||||
"HELP": "Automatically save all data to your app folder to have it ready if something goes wrong.",
|
||||
"LABEL_IS_ENABLED": "Enable automatic backups",
|
||||
|
|
@ -2021,8 +1995,8 @@
|
|||
"SHOW_BANNER_THRESHOLD": "Show a notification X before the event (blank for disabled)"
|
||||
},
|
||||
"CLIPBOARD_IMAGES": {
|
||||
"DELETE": "Șterge imaginea",
|
||||
"DELETE_ALL": "Șterge-le pe toate",
|
||||
"DELETE": "Delete image",
|
||||
"DELETE_ALL": "Delete All",
|
||||
"DELETE_ALL_SUCCESS": "Toate imaginile din clipboard șterse cu succes",
|
||||
"DELETE_SUCCESS": "Imagine din clipboard ștearsă cu succes",
|
||||
"ERROR_DELETING": "Ștergerea imaginii din clipboard a eșuat",
|
||||
|
|
@ -2039,14 +2013,14 @@
|
|||
"PATH_LABEL": "Cale stocare imagini",
|
||||
"PATH_PLACEHOLDER": "Lasă gol pentru locația implicită",
|
||||
"PATH_TITLE": "Locație stocare",
|
||||
"SELECT_PATH": "Selectează dosarul",
|
||||
"SELECT_PATH": "Select Folder",
|
||||
"SELECT_PATH_TITLE": "Selectează dosarul pentru imagini clipboard",
|
||||
"SORT_BY": "Sortează după",
|
||||
"SORT_LARGEST": "Cele mai mari primele",
|
||||
"SORT_NEWEST": "Cele mai noi primele",
|
||||
"SORT_OLDEST": "Cele mai vechi primele",
|
||||
"SORT_SMALLEST": "Cele mai mici primele",
|
||||
"TITLE": "Imagini Clipboard",
|
||||
"SORT_BY": "Sort by",
|
||||
"SORT_LARGEST": "Largest first",
|
||||
"SORT_NEWEST": "Newest first",
|
||||
"SORT_OLDEST": "Oldest first",
|
||||
"SORT_SMALLEST": "Smallest first",
|
||||
"TITLE": "Imagini clipboard",
|
||||
"TOTAL_IMAGES": "Total imagini",
|
||||
"TOTAL_SIZE": "Dimensiune totală"
|
||||
},
|
||||
|
|
@ -2175,7 +2149,6 @@
|
|||
"TIME_LOCALE_IT_IT": "Italienesc: 24 de ore, DD/MM/YYYY",
|
||||
"TIME_LOCALE_JA_JP": "Japonez: 24 de ore, YYYY/MM/DD",
|
||||
"TIME_LOCALE_KO_KR": "Korean: 12 ore AM/PM, YYYY. MM. DD",
|
||||
"TIME_LOCALE_PL_PL": "Polonez: 24 de ore, DD.MM.YYYY",
|
||||
"TIME_LOCALE_PT_BR": "Portughez (Brazilia): 24 de ore, DD/MM/YYYY",
|
||||
"TIME_LOCALE_RO_MD": "Românesc (Moldova): 24 de ore, DD.MM.YYYY",
|
||||
"TIME_LOCALE_RO_RO": "Românesc: 24 de ore, DD.MM.YYYY",
|
||||
|
|
@ -2245,10 +2218,10 @@
|
|||
"L_LUNCH_BREAK_START": "Lunch break start",
|
||||
"L_WORK_END": "Work Day End",
|
||||
"L_WORK_START": "Work Day Start",
|
||||
"LUNCH_BREAK_START_END_DESCRIPTION": "d.e. 13:00",
|
||||
"LUNCH_BREAK_START_END_DESCRIPTION": "ex: 13:00",
|
||||
"MONTH": "Lună",
|
||||
"TITLE": "Programare",
|
||||
"WORK_START_END_DESCRIPTION": "d.e. 17:00"
|
||||
"WORK_START_END_DESCRIPTION": "ex: 17:00"
|
||||
},
|
||||
"SHORT_SYNTAX": {
|
||||
"HELP": "<p>Here you can control short syntax options when creating a task</p>",
|
||||
|
|
@ -2262,9 +2235,9 @@
|
|||
"URL_BEHAVIOR_LABEL": "URL behavior when adding tasks"
|
||||
},
|
||||
"SOUND": {
|
||||
"BREAK_REMINDER_SOUND": "Sunet memento de luat o pauză",
|
||||
"DONE_SOUND": "Sunet sarcină finalizată",
|
||||
"IS_INCREASE_DONE_PITCH": "Crește tonul pentru fiecare sarcină finalizată",
|
||||
"BREAK_REMINDER_SOUND": "Take a break reminder sound",
|
||||
"DONE_SOUND": "Task completed sound",
|
||||
"IS_INCREASE_DONE_PITCH": "Increase pitch for every task completed",
|
||||
"TITLE": "Sunet",
|
||||
"TRACK_TIME_SOUND": "Sunet memento de urmărire a timpului",
|
||||
"VOLUME": "Volum"
|
||||
|
|
@ -2673,19 +2646,18 @@
|
|||
"CREATE_NEW_PROFILE": "Creează un nou profil",
|
||||
"CREATED": "Creat:",
|
||||
"DELETE_PROFILE": "Șterge profilul",
|
||||
"DEPRECATION_WARNING": "Funcția experimentală Profile utilizator va fi eliminată într-o actualizare viitoare. Te rog să îți exporti datele de profil ca backup.",
|
||||
"DIALOG_TITLE": "Administrează profilurile de utilizator",
|
||||
"EXISTING_PROFILES": "Profiluri existente",
|
||||
"EXPORT_PROFILE": "Exportă profilul",
|
||||
"MANAGE_PROFILES": "Gestionează profilurile...",
|
||||
"PROFILE_NAME": "Nume profil",
|
||||
"PROFILE_NAME_PLACEHOLDER": "d.e. Muncă, Personal",
|
||||
"PROFILE_NAME_PLACEHOLDER": "ex: Muncă, Personal",
|
||||
"RENAME": "Redenumește",
|
||||
"SAVE": "Salvează"
|
||||
},
|
||||
"V": {
|
||||
"E_DATETIME": "Valoare întrodusă nu este o dată și oră!",
|
||||
"E_DURATION": "Te rog întrodu o durată validă (d.e. 1h)",
|
||||
"E_DURATION": "Te rog întrodu o durată validă (ex: 1h)",
|
||||
"E_MAX": "Nu trebuie să fie mai mare decît {{val}}",
|
||||
"E_MAX_LENGTH": "Trebuie să aibă cel mult {{val}} caractere",
|
||||
"E_MIN": "Nu trebuie să fie mai mică decît {{val}}",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"BY": "după",
|
||||
"NO_RESULTS": "Nicio imagine găsită. Te rog încearcă un alt termen de căutare.",
|
||||
"SEARCH_LABEL": "Caută imagini",
|
||||
"SEARCH_PLACEHOLDER": "d.e. natură, munți, abstract",
|
||||
"SEARCH_PLACEHOLDER": "ex: natură, munți, abstract",
|
||||
"TITLE": "Alege o imagine de fundal din Unsplash"
|
||||
},
|
||||
"DONATE_PAGE": {
|
||||
|
|
@ -94,8 +94,8 @@
|
|||
"ADD_NEW_PANEL": "Adaugă un nou panou",
|
||||
"BACKLOG_TASK_FILTER_ALL": "Toate",
|
||||
"BACKLOG_TASK_FILTER_NO_BACKLOG": "Exclude",
|
||||
"BACKLOG_TASK_FILTER_ONLY_BACKLOG": "Doar backlog",
|
||||
"BACKLOG_TASK_FILTER_TYPE": "Sarcini din backlog",
|
||||
"BACKLOG_TASK_FILTER_ONLY_BACKLOG": "Doar restante",
|
||||
"BACKLOG_TASK_FILTER_TYPE": "Sarcini restante",
|
||||
"COLUMNS": "Coloane",
|
||||
"TAGS_EXCLUDED": "Etichete excluse",
|
||||
"TAGS_REQUIRED": "Etichete obligatorii",
|
||||
|
|
@ -167,18 +167,8 @@
|
|||
"EVENT_STR": "eveniment",
|
||||
"EVENTS_STR": "evenimente"
|
||||
},
|
||||
"CONTEXT_MENU": {
|
||||
"CREATE_TASK": "Creează ca sarcină",
|
||||
"OPEN_IN_CALENDAR": "Deschide în calendar",
|
||||
"RESCHEDULE": "Replanifică",
|
||||
"DELETE_EVENT": "Șterge evenimentul"
|
||||
},
|
||||
"S": {
|
||||
"CAL_PROVIDER_ERROR": "Eroare furnizor calendar: {{errTxt}}",
|
||||
"EVENT_HIDDEN": "Eveniment calendar ascuns",
|
||||
"EVENT_RESCHEDULED": "Eveniment reprogramat",
|
||||
"EVENT_DELETED": "Eveniment șters",
|
||||
"TIME_BLOCK_ERROR": "Nu s-a putut sincroniza blocul de timp: {{errTxt}}"
|
||||
"CAL_PROVIDER_ERROR": "Eroare furnizor calendar: {{errTxt}}"
|
||||
}
|
||||
},
|
||||
"CLIPBOARD_IMAGE": {
|
||||
|
|
@ -275,7 +265,7 @@
|
|||
},
|
||||
"GITEA": {
|
||||
"FORM": {
|
||||
"HOST": "Gazdă (d.e. https://try.gitea.io)",
|
||||
"HOST": "Gazdă (ex: https://try.gitea.io)",
|
||||
"REPO_FULL_NAME": "Nume utilizator sau nume organizație/proiect",
|
||||
"REPO_FULL_NAME_DESCRIPTION": "Poate fi găsit ca parte a URL-ului când vizualizezi proiectul într-un navigator web.",
|
||||
"SCOPE": "Domeniu de aplicare",
|
||||
|
|
@ -313,7 +303,7 @@
|
|||
"FILTER_USER": "Filtrează după numele de utilizator",
|
||||
"GITLAB_BASE_URL": "URL de bază personalizat GitLab (opțional)",
|
||||
"PROJECT": "nume utilizator/proiect",
|
||||
"PROJECT_HINT": "d.e. super-productivity/super-productivity",
|
||||
"PROJECT_HINT": "ex: super-productivity/super-productivity",
|
||||
"SCOPE": "Domeniu de aplicare",
|
||||
"SCOPE_ALL": "Toate",
|
||||
"SCOPE_ASSIGNED": "Atribuite mie",
|
||||
|
|
@ -431,16 +421,8 @@
|
|||
"DIALOG": {
|
||||
"ADVANCED_CONFIG": "Configurare avansată",
|
||||
"CONNECTED": "Conectat",
|
||||
"DELETE_CONFIRM": "Dorești să ștergi acest furnizor de probleme? Ștergerea înseamnă că <strong>toate sarcinile de probleme importate anterior vor pierde referința</strong>. Aceasta acțiune nu poate fi anulată!",
|
||||
"DISCONNECT": "Deconectează",
|
||||
"EDIT_TITLE": "Editează configurația {{title}}",
|
||||
"LOAD_OPTIONS": "Încarcă opțiunile",
|
||||
"LOAD_OPTIONS_FAILED": "Nu s-au putut încărca opțiunile",
|
||||
"LOADING_OPTIONS": "Se încarcă opțiunile...",
|
||||
"OPTIONS_LOADED": "Opțiuni încărcate",
|
||||
"RELOAD_OPTIONS": "Reîncarcă opțiunile",
|
||||
"SETUP_TITLE": "Editează configurația {{title}}",
|
||||
"TEST_CONNECTION": "Testează conexiunea"
|
||||
"DELETE_CONFIRM": "Dorești să ștergi acest furnizor de probleme? Ștergerea înseamnă că <strong>toate sarcinile de probleme importate anterior vor pierde referința</strong>. Aceasta nu poate fi anulată!",
|
||||
"DISCONNECT": "Deconectează"
|
||||
}
|
||||
},
|
||||
"ISSUE_PANEL": {
|
||||
|
|
@ -450,37 +432,37 @@
|
|||
},
|
||||
"JIRA": {
|
||||
"BANNER": {
|
||||
"BLOCK_ACCESS_MSG": "Jira: Pentru a preveni blocarea API-ului, accesul a fost blocat de Super Productivity. Probabil ar trebui să verifici setările Jira!",
|
||||
"BLOCK_ACCESS_UNBLOCK": "Deblochează"
|
||||
"BLOCK_ACCESS_MSG": "Jira: To prevent API lock out, access has been blocked by Super Productivity. You probably should check your Jira settings!",
|
||||
"BLOCK_ACCESS_UNBLOCK": "Unblock"
|
||||
},
|
||||
"CFG_CMP": {
|
||||
"ALWAYS_ASK": "Deschide mereu dialogul",
|
||||
"DO_NOT": "Nu tranziționa",
|
||||
"DONE": "Stare pentru finalizarea sarcinii",
|
||||
"ENABLE": "Activează integrarea Jira",
|
||||
"ENABLE_TRANSITIONS": "Activează gestionarea tranzițiilor",
|
||||
"IN_PROGRESS": "Stare pentru începerea sarcinii",
|
||||
"LOAD_SUGGESTIONS": "Încarcă sugestiile",
|
||||
"MAP_CUSTOM_FIELDS": "Încarcă Story Points",
|
||||
"MAP_CUSTOM_FIELDS_INFO": "Din păcate unele din datele Jira sunt salvate sub câmpuri particulare (custom fields) care sunt diferite pentru fiecare instalare. Pentru a include aceste date, trebuie să selectezi câmpul personalizat adecvat. Momentan, există doar câmpul pentru Story Points care trebuie asociat.",
|
||||
"OPEN": "Stare pentru punerea pe pauză a sarcinii",
|
||||
"SELECT_ISSUE_FOR_TRANSITIONS": "Selectează problema pentru a încărca tranzițiile disponibile",
|
||||
"STORY_POINTS": "Nume câmp Story Points",
|
||||
"TRANSITION": "Gestionarea tranzițiilor"
|
||||
"ALWAYS_ASK": "Always open dialog",
|
||||
"DO_NOT": "Don't transition",
|
||||
"DONE": "Status for completing task",
|
||||
"ENABLE": "Enable Jira integration",
|
||||
"ENABLE_TRANSITIONS": "Enable Transition Handling",
|
||||
"IN_PROGRESS": "Status for starting task",
|
||||
"LOAD_SUGGESTIONS": "Load Suggestions",
|
||||
"MAP_CUSTOM_FIELDS": "Load Story Points",
|
||||
"MAP_CUSTOM_FIELDS_INFO": "Unfortunately some of Jira's data is saved under custom fields which are different for every installation. To include this data, you need to select the proper custom field for it. Currently there is only the story points field that needs to be mapped.",
|
||||
"OPEN": "Status for pausing task",
|
||||
"SELECT_ISSUE_FOR_TRANSITIONS": "Select issue to load available transitions",
|
||||
"STORY_POINTS": "Story Points Field Name",
|
||||
"TRANSITION": "Transition Handling"
|
||||
},
|
||||
"DIALOG_CONFIRM_ASSIGNMENT": {
|
||||
"MSG": "<strong>{{summary}}</strong> Este asociat curent cu <strong>{{assignee}}</strong>. Dorești să îl asociezi pentru tine?",
|
||||
"OK": "Fă-o!"
|
||||
"MSG": "<strong>{{summary}}</strong> is currently assigned to <strong>{{assignee}}</strong>. Do you want to assign it to yourself?",
|
||||
"OK": "Do it!"
|
||||
},
|
||||
"DIALOG_INITIAL": {
|
||||
"TITLE": "Configurează Jira pentru proiect"
|
||||
"TITLE": "Setup Jira for Project"
|
||||
},
|
||||
"DIALOG_TRANSITION": {
|
||||
"CHOOSE_STATUS": "Alege starea de atribuit",
|
||||
"CURRENT_ASSIGNEE": "Atribuit curent:",
|
||||
"CURRENT_STATUS": "Starea curentă:",
|
||||
"TASK_NAME": "Nume sarcină:",
|
||||
"TITLE": "Jira: Actualizează starea"
|
||||
"CHOOSE_STATUS": "Choose status to assign",
|
||||
"CURRENT_ASSIGNEE": "Current Assignee:",
|
||||
"CURRENT_STATUS": "Current Status:",
|
||||
"TASK_NAME": "Task name:",
|
||||
"TITLE": "Jira: Update Status"
|
||||
},
|
||||
"DIALOG_WORKLOG": {
|
||||
"CHECKBOXES": {
|
||||
|
|
@ -947,32 +929,32 @@
|
|||
"TEXT": "<p>Orarul îți oferă o viziune de ansamblu despre cum se desfășoară sarcinile planificate în timp. Este generat automat din sarcinile tale și necesită doar <strong>estimări de timp</strong> pentru a funcționa.</p><p>Două lucruri sunt distinse: <strong>Sarcini programate</strong>, care sunt afișate la ora planificată și <strong>Sarcini obișnuite</strong>, care ar trebui să curgă în jurul acestor evenimente fixe.</p><p>Dacă setezi o oră de început și sfârșit a lucrului (recomandat), sarcinile obișnuite nu vor fi niciodată plasate în afara acestor limite.</p>",
|
||||
"TITLE": "Orar"
|
||||
},
|
||||
"END": "Finalul muncii",
|
||||
"INSERT_BEFORE": "Înainte",
|
||||
"LUNCH_BREAK": "Pauză de masă",
|
||||
"MONTH": "Lună",
|
||||
"NO_TASKS": "Momentan nu există sarcini. Adaugă câteva sarcini prin butonul plus (+) din bara de sus.",
|
||||
"NOW": "Acum",
|
||||
"PLAN_END_DAY": "Final de {{date}}",
|
||||
"PLAN_START_DAY": "Început de {{date}}",
|
||||
"SHIFT_KEY_INFO": "Ține apăsat Shift pentru a comuta modul de planificare zilnică",
|
||||
"START": "Începerea muncii"
|
||||
"END": "Work End",
|
||||
"INSERT_BEFORE": "Before",
|
||||
"LUNCH_BREAK": "Lunch Break",
|
||||
"MONTH": "Month",
|
||||
"NO_TASKS": "Currently there are no tasks. Please add some tasks via the plus (+) button in the top bar.",
|
||||
"NOW": "Now",
|
||||
"PLAN_END_DAY": "End of {{date}}",
|
||||
"PLAN_START_DAY": "Start of {{date}}",
|
||||
"SHIFT_KEY_INFO": "Hold Shift to toggle day planning mode",
|
||||
"START": "Work Start"
|
||||
},
|
||||
"SEARCH_BAR": {
|
||||
"INFO": "Începe să tastezi pentru a căuta sarcini",
|
||||
"NO_RESULTS": "Nu s-au găsit sarcini care să se potrivească căutării",
|
||||
"PLACEHOLDER": "Caută sarcină sau notițe despre sarcină"
|
||||
"INFO": "Start typing to search for tasks",
|
||||
"NO_RESULTS": "No tasks found matching your search",
|
||||
"PLACEHOLDER": "Search for task or task notes"
|
||||
},
|
||||
"SIMPLE_COUNTER": {
|
||||
"D_CONFIRM_REMOVE": {
|
||||
"MSG": "Ștergerea unui contor simplu va șterge și toate datele urmărite pe el. Ești sigur că vrei să continui?",
|
||||
"OK": "Fă-o!"
|
||||
"MSG": "Deleting a simple counter, will also delete all past data tracked on it. Are you sure you want to proceed?",
|
||||
"OK": "Do it!"
|
||||
},
|
||||
"D_EDIT": {
|
||||
"CURRENT_STREAK": "Serie curentă",
|
||||
"DAILY_GOAL": "Obiectiv zilnic",
|
||||
"DAYS": "Zile",
|
||||
"L_COUNTER": "Număr"
|
||||
"CURRENT_STREAK": "Current Streak",
|
||||
"DAILY_GOAL": "Daily Goal",
|
||||
"DAYS": "Days",
|
||||
"L_COUNTER": "Count"
|
||||
},
|
||||
"FORM": {
|
||||
"ADD_NEW": "Adaugă un contor simplu sau un obicei",
|
||||
|
|
@ -985,7 +967,7 @@
|
|||
"L_IS_HIDE_BUTTON": "Ascunde butonul din bara de unelte",
|
||||
"L_STREAK_MODE": "Mod urmărire serie",
|
||||
"L_STREAK_MODE_SPECIFIC_DAYS": "Zilele săptămânii",
|
||||
"L_STREAK_MODE_WEEKLY_FREQUENCY": "Frecvență săptămânală (d.e. de 3 ori pe săptămână)",
|
||||
"L_STREAK_MODE_WEEKLY_FREQUENCY": "Frecvență săptămânală (ex: de 3 ori pe săptămână)",
|
||||
"L_TITLE": "Titlu",
|
||||
"L_TRACK_STREAKS": "Urmărește seriile",
|
||||
"L_TYPE": "Tip",
|
||||
|
|
@ -1018,40 +1000,40 @@
|
|||
"FORCE_UPLOAD": "Forțarea încărcării datelor poate duce la pierderea acestora. Ești sigur că vrei să continui?"
|
||||
},
|
||||
"D_AUTH_CODE": {
|
||||
"FOLLOW_LINK": "Te rog deschide următoarea legătură și copiază codul de autorizare furnizat acolo în câmpul de intrare de mai jos.",
|
||||
"FOLLOW_LINK_MOBILE": "Te rog apasă butonul de mai jos pentru a autoriza. Vei fi redirecționat automat înapoi la aplicație.",
|
||||
"GET_AUTH_CODE": "Obține codul de autorizare",
|
||||
"L_AUTH_CODE": "Introdu codul de autorizare",
|
||||
"TITLE": "Autentificare: {{provider}}",
|
||||
"WAITING_FOR_AUTH": "Se așteaptă autentificarea..."
|
||||
"FOLLOW_LINK": "Please open the following link and copy the auth code provided there into the input field below.",
|
||||
"FOLLOW_LINK_MOBILE": "Please tap the button below to authorize. You will be automatically redirected back to the app.",
|
||||
"GET_AUTH_CODE": "Get Authorization Code",
|
||||
"L_AUTH_CODE": "Enter Auth Code",
|
||||
"TITLE": "Login: {{provider}}",
|
||||
"WAITING_FOR_AUTH": "Waiting for authentication..."
|
||||
},
|
||||
"D_CONFLICT": {
|
||||
"ADDITIONAL_INFO": "Informații suplimentare",
|
||||
"CHANGES": "Modificări",
|
||||
"CHANGES_SINCE_LAST_SYNC": "Modificări de la ultima sincronizare",
|
||||
"COMPARISON_RESULT": "Rezultatul comparării",
|
||||
"DATE": "Dată",
|
||||
"LAST_SYNC": "Ultima Sincronizare:",
|
||||
"LAST_SYNCED": "Ultima sincronizare",
|
||||
"LAST_WRITE": "Ultima scriere",
|
||||
"ADDITIONAL_INFO": "Additional Info",
|
||||
"CHANGES": "Changes",
|
||||
"CHANGES_SINCE_LAST_SYNC": "Changes since last sync",
|
||||
"COMPARISON_RESULT": "Comparison Result",
|
||||
"DATE": "Date",
|
||||
"LAST_SYNC": "Last Sync:",
|
||||
"LAST_SYNCED": "Last Synced",
|
||||
"LAST_WRITE": "Last Write",
|
||||
"LOCAL": "Local",
|
||||
"LOCAL_REMOTE": "Local -> Distant",
|
||||
"NEVER": "Niciodată",
|
||||
"OVERWRITE_WARNING": "ATENȚIE: Datele {{targetName}} conțin aproximativ {{targetChanges}} modificări în timp ce datele {{sourceName}} au doar {{sourceChanges}} modificări. Ești sigur că vrei să înlocuiești datele {{targetName}} cu versiunea {{sourceName}}? Această acțiune nu poate fi anulată.",
|
||||
"REMOTE": "Distant",
|
||||
"RESULT": "Rezultat",
|
||||
"TEXT": "<p>Actualizează de la distanță. Atât datele locale cât și cele de pe server par modificate.</p>",
|
||||
"TIME": "Timp",
|
||||
"TIMESTAMP": "Marca temporală",
|
||||
"TITLE": "Sincronizare: Date conflictuale",
|
||||
"USE_LOCAL": "Păstrează local",
|
||||
"USE_REMOTE": "Păstrează distant",
|
||||
"VECTOR_CLOCK": "Ceas vectorial",
|
||||
"VECTOR_CLOCK_HEADING": "Ceas vectorial",
|
||||
"VECTOR_COMPARISON_CONCURRENT": "Concurent (conflict adevărat)",
|
||||
"VECTOR_COMPARISON_EQUAL": "Egal",
|
||||
"VECTOR_COMPARISON_LOCAL_GREATER": "Local > Distant",
|
||||
"VECTOR_COMPARISON_LOCAL_LESS": "Local < Distant"
|
||||
"LOCAL_REMOTE": "Local -> Remote",
|
||||
"NEVER": "Never",
|
||||
"OVERWRITE_WARNING": "WARNING: The {{targetName}} data contains approximately {{targetChanges}} changes while the {{sourceName}} data only has {{sourceChanges}} changes. Are you sure you want to overwrite the {{targetName}} data with the {{sourceName}} version? This action cannot be undone.",
|
||||
"REMOTE": "Remote",
|
||||
"RESULT": "Result",
|
||||
"TEXT": "<p>Update from remote. Both local and remote data seem to be modified.</p>",
|
||||
"TIME": "Time",
|
||||
"TIMESTAMP": "Timestamp",
|
||||
"TITLE": "Sync: Conflicting Data",
|
||||
"USE_LOCAL": "Keep local",
|
||||
"USE_REMOTE": "Keep remote",
|
||||
"VECTOR_CLOCK": "Vector Clock",
|
||||
"VECTOR_CLOCK_HEADING": "Vector Clock",
|
||||
"VECTOR_COMPARISON_CONCURRENT": "Concurrent (True Conflict)",
|
||||
"VECTOR_COMPARISON_EQUAL": "Equal",
|
||||
"VECTOR_COMPARISON_LOCAL_GREATER": "Local > Remote",
|
||||
"VECTOR_COMPARISON_LOCAL_LESS": "Local < Remote"
|
||||
},
|
||||
"D_DATA_REPAIR_CONFIRM": {
|
||||
"MSG": "Datele tale par deteriorate. Dorești să încerci repararea automată? Aceasta poate duce la pierderea minoră de date.",
|
||||
|
|
@ -1063,28 +1045,28 @@
|
|||
"UNKNOWN_ERROR": "Eroare necunoscută de sincronizare: {{err}}"
|
||||
},
|
||||
"D_DECRYPT_ERROR": {
|
||||
"BTN_OVER_WRITE_REMOTE": "Folosește această parolă și suprascrie-o pe cea de la distanță",
|
||||
"CHANGE_PW_AND_DECRYPT": "Reîncearcă decriptarea",
|
||||
"P1": "Datele tale de la distanță sunt criptate și decriptarea a eșuat.",
|
||||
"P2_FORGOT_PW": "Dacă ai <strong>uitat parola</strong>, introdu parola corectă și apasă \"Reîncearcă decriptarea\".",
|
||||
"P3_PW_CHANGED": "Dacă ai <strong>schimbat parola pe un alt dispozitiv</strong>, introdu noua parolă și apasă \"Suprascrie datele de la distanță\". Aceasta va încărca datele locale criptate cu această parolă pe server.",
|
||||
"PASSWORD": "Parolă",
|
||||
"TITLE": "Decriptarea a eșuat"
|
||||
"BTN_OVER_WRITE_REMOTE": "Use This Password & Overwrite Remote",
|
||||
"CHANGE_PW_AND_DECRYPT": "Retry Decrypt",
|
||||
"P1": "Your remote data is encrypted and decryption failed.",
|
||||
"P2_FORGOT_PW": "If you <strong>forgot your password</strong>, enter the correct password and click \"Retry Decrypt\".",
|
||||
"P3_PW_CHANGED": "If you <strong>changed your password on another device</strong>, enter your new password and click \"Overwrite Remote\". This will upload your local data to the server.",
|
||||
"PASSWORD": "Password",
|
||||
"TITLE": "Decryption Failed"
|
||||
},
|
||||
"D_ENTER_PASSWORD": {
|
||||
"BTN_FORCE_OVERWRITE": "Folosește datele locale",
|
||||
"BTN_SAVE_AND_SYNC": "Salvează și sincronizează",
|
||||
"FORCE_OVERWRITE_CONFIRM": "Acest lucru va <strong>șterge toate datele de pe server</strong> și va încărca datele locale criptate cu această parolă. Continui?",
|
||||
"FORCE_OVERWRITE_INFO": "Dacă preferi să păstrezi datele locale, poți suprascrie serverul în schimb.",
|
||||
"FORCE_OVERWRITE_TITLE": "Suprascrii datele de pe server?",
|
||||
"MESSAGE": "Serverul de sincronizare conține date criptate, dar nu este configurată nicio parolă de criptare pe acest dispozitiv. Te rog să introduci parola de criptare pentru a decripta și sincroniza datele.",
|
||||
"PASSWORD_LABEL": "Parolă de criptare",
|
||||
"TITLE": "Parolă de criptare necesară"
|
||||
"BTN_FORCE_OVERWRITE": "Use Local Data",
|
||||
"BTN_SAVE_AND_SYNC": "Save & Sync",
|
||||
"FORCE_OVERWRITE_CONFIRM": "This will <strong>delete all data on the server</strong> and upload your local data encrypted with this password. Continue?",
|
||||
"FORCE_OVERWRITE_INFO": "If you prefer to keep your local data, you can overwrite the server instead.",
|
||||
"FORCE_OVERWRITE_TITLE": "Overwrite Server Data?",
|
||||
"MESSAGE": "The sync server contains encrypted data, but no encryption password is configured on this device. Please enter your encryption password to decrypt and sync your data.",
|
||||
"PASSWORD_LABEL": "Encryption Password",
|
||||
"TITLE": "Encryption Password Required"
|
||||
},
|
||||
"D_FRESH_CLIENT_CONFIRM": {
|
||||
"MESSAGE": "Aceasta pare să fie o instalare curată. Datele de la distanță cu {{count}} modificări au fost găsite. Dorești să descarci și să înlocuiești datele locale cu ele?",
|
||||
"OK": "Descarcă datele de la distanță",
|
||||
"TITLE": "Sincronizare inițială"
|
||||
"MESSAGE": "This appears to be a fresh installation. Remote data with {{count}} changes was found. Do you want to download and overwrite your local data with it?",
|
||||
"OK": "Download Remote Data",
|
||||
"TITLE": "Initial Sync"
|
||||
},
|
||||
"D_INCOMPLETE_SYNC": {
|
||||
"BTN_CLOSE_APP": "Închide aplicația",
|
||||
|
|
@ -1289,17 +1271,13 @@
|
|||
"SETUP_ENCRYPTION_PASSWORD_WARNING": "Folosește aceeași parolă pe toate dispozitivele. O parolă uitată poate fi resetată prin suprascrierea datelor de la distanță.",
|
||||
"SETUP_ENCRYPTION_TITLE": "SuperSync: Setare parolă"
|
||||
},
|
||||
"NEXTCLOUD": {
|
||||
"L_SERVER_URL": "Nextcloud Server URL",
|
||||
"SERVER_URL_DESCRIPTION": "e.g. https://cloud.example.com or https://example.com/nextcloud",
|
||||
"L_APP_PASSWORD": "App Password",
|
||||
"APP_PASSWORD_DESCRIPTION": "Go to Nextcloud → Settings → Security → App passwords to generate one"
|
||||
},
|
||||
"TITLE": "Sync",
|
||||
"WEB_DAV": {
|
||||
"CORS_INFO": "<strong>Making it work in a web browser:</strong> You need to allow Super Productivity to make CORS requests to your server. For Nextcloud, one approach is allowing \"https://app.super-productivity.com\" via the <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword</a> app. Refer to <a href='https://github.com/nextcloud/server/issues/3131'>this GitHub thread</a> for more information.",
|
||||
"CONDITIONAL_HEADER_WARNING_MSG": "Your WebDAV server does not support conditional headers (If-Unmodified-Since). This means Super Productivity cannot detect concurrent changes from other devices during upload.<br><br>Sync will still work, but there is a small risk of data conflicts if you make changes on multiple devices simultaneously.",
|
||||
"CONDITIONAL_HEADER_WARNING_TITLE": "Reduced Sync Safety",
|
||||
"CORS_INFO": "<strong>Making it work in a web browser:</strong> Allow Super Productivity to make CORS requests to your WebDAV server. This can have negative security implications! For Nextcloud, please refer to <a href='https://github.com/nextcloud/server/issues/3131'>this GitHub thread</a> for more information. One approach to make this work on mobile is allowing \"https://app.super-productivity.com\" via the Nextcloud app <a href='https://apps.nextcloud.com/apps/webapppassword'>webapppassword<a>. Use at your own risk!</p>",
|
||||
"D_SYNC_FOLDER_PATH": "Path relative to the WebDAV server root where sync files will be stored (e.g. '/super-productivity' or '/'). This is NOT your server's internal directory path.",
|
||||
"INFO": "<strong>Warning:</strong> Generic WebDAV sync is provided <strong>as-is without support</strong>. If you are using Nextcloud, please use the Nextcloud sync option instead.",
|
||||
"INFO": "<strong>Warning:</strong> WebDAV implementations differ wildly. Super Productivity is known to work well with Nextcloud, <strong>but it may not work with your provider</strong>. Test thoroughly before relying on it.",
|
||||
"L_BASE_URL": "Base URL",
|
||||
"L_PASSWORD": "Password",
|
||||
"L_SYNC_FOLDER_PATH": "Sync Folder Path",
|
||||
|
|
@ -1337,8 +1315,6 @@
|
|||
"ERROR_PERMISSION": "File access denied. Please check your filesystem permissions.",
|
||||
"ERROR_PERMISSION_FLATPAK": "File access denied. Grant filesystem permission via Flatseal or use a path inside ~/.var/app/",
|
||||
"ERROR_PERMISSION_SNAP": "File access denied. Run 'snap connect super-productivity:home' or use a path inside ~/snap/super-productivity/common/",
|
||||
"ERROR_REMOTE_FILE_EMPTY": "Remote sync file is empty. This can happen after an interrupted sync. Overwrite remote with your local data?",
|
||||
"ERROR_REMOTE_FILE_LOCKED": "Remote sync file is locked by the server. This may resolve on the next sync attempt.",
|
||||
"ERROR_UNABLE_TO_READ_REMOTE_DATA": "Error while syncing. Unable to read remote data. Maybe you enabled encryption and your local password does not match the one used to encrypt the remote data?",
|
||||
"FINISH_DAY_SYNC_ERROR": "Sync error while finishing day. Please try again.",
|
||||
"FRESH_CLIENT_SYNC_CANCELLED": "Sincronizare inițială anulată. Datele de la distanță nu au fost aplicate.",
|
||||
|
|
@ -1429,11 +1405,11 @@
|
|||
"TITLE": "Basic Settings"
|
||||
},
|
||||
"S": {
|
||||
"ARCHIVE_CLEANUP_FAILED": "A eșuat curățarea datelor arhivate pentru eticheta ștearsă",
|
||||
"UPDATED": "Setările etichetei au fost actualizate"
|
||||
"ARCHIVE_CLEANUP_FAILED": "Curățarea datelor arhivate pentru eticheta ștearsă a eșuat",
|
||||
"UPDATED": "Tag settings were updated"
|
||||
},
|
||||
"TTL": {
|
||||
"ADD_NEW_TAG": "Adaugă etichetă nouă"
|
||||
"ADD_NEW_TAG": "Add new Tag"
|
||||
}
|
||||
},
|
||||
"TAG_FOLDER": {
|
||||
|
|
@ -1513,47 +1489,47 @@
|
|||
"ADD_SUB_TASK": "Adaugă subsarcină",
|
||||
"ADD_TO_MY_DAY": "Adaugă la lista de azi",
|
||||
"ADD_TO_PROJECT": "Adaugă la proiect",
|
||||
"CONVERT_TO_PARENT_TASK": "Convertește în sarcină părinte",
|
||||
"DELETE": "Șterge sarcina",
|
||||
"DELETE_REPEAT_INSTANCE": "Șterge instanța de sarcină recurentă",
|
||||
"DROP_ATTACHMENT": "Plasează aici pentru a atașa la \"{{title}}\"",
|
||||
"DUPLICATE": "Duplică",
|
||||
"CONVERT_TO_PARENT_TASK": "Convert to parent task",
|
||||
"DELETE": "Delete task",
|
||||
"DELETE_REPEAT_INSTANCE": "Delete recurring task instance",
|
||||
"DROP_ATTACHMENT": "Drop here to attach to \"{{title}}\"",
|
||||
"DUPLICATE": "Duplicate",
|
||||
"EDIT_DEADLINE": "Editează termen limită",
|
||||
"EDIT_SCHEDULED": "Planifică din nou",
|
||||
"FOCUS_SESSION": "Începe sesiune de concentrare",
|
||||
"MARK_DONE": "Marchează ca finalizată",
|
||||
"MOVE_TO_BACKLOG": "Mută în backlog",
|
||||
"MOVE_TO_OTHER_PROJECT": "Mută în proiect",
|
||||
"MOVE_TO_REGULAR": "Mută în lista obișnuită",
|
||||
"MOVE_TO_TOP": "Mută în partea de sus",
|
||||
"OPEN_ATTACH": "Atașează fișier sau legătură",
|
||||
"OPEN_ISSUE": "Deschide în navigatorul web",
|
||||
"OPEN_TIME": "Contorizare timp",
|
||||
"EDIT_SCHEDULED": "Reschedule",
|
||||
"FOCUS_SESSION": "Start focus session",
|
||||
"MARK_DONE": "Mark as completed",
|
||||
"MOVE_TO_BACKLOG": "Move to backlog",
|
||||
"MOVE_TO_OTHER_PROJECT": "Move to project",
|
||||
"MOVE_TO_REGULAR": "Move to regular list",
|
||||
"MOVE_TO_TOP": "Move to top",
|
||||
"OPEN_ATTACH": "Attach file or link",
|
||||
"OPEN_ISSUE": "Open in browser",
|
||||
"OPEN_TIME": "Time tracking",
|
||||
"REMOVE_DEADLINE": "Șterge termen limită",
|
||||
"REMOVE_FROM_MY_DAY": "Elimină din lista de azi",
|
||||
"SCHEDULE": "Planifică sarcina",
|
||||
"SCHEDULE": "Schedule task",
|
||||
"SET_DEADLINE": "Setează termen limită",
|
||||
"TOGGLE_ATTACHMENTS": "Arată/Ascunde atașamente",
|
||||
"TOGGLE_DETAIL_PANEL": "Arată/Ascunde informații suplimentare",
|
||||
"TOGGLE_DONE": "Comută starea de finalizare",
|
||||
"TOGGLE_SUB_TASK_VISIBILITY": "Comută vizibilitatea subsarcinilor",
|
||||
"TOGGLE_TAGS": "Comută etichetele",
|
||||
"TRACK_TIME": "Începe contorizarea timpului",
|
||||
"TRACK_TIME_STOP": "Pune pe pauză contorizarea timpului",
|
||||
"UNSCHEDULE_TASK": "Șterge planificarea sarcinii",
|
||||
"UPDATE_ISSUE_DATA": "Actualizează datele problemei"
|
||||
"TOGGLE_ATTACHMENTS": "Show/hide attachments",
|
||||
"TOGGLE_DETAIL_PANEL": "Show/hide additional info",
|
||||
"TOGGLE_DONE": "Toggle completion status",
|
||||
"TOGGLE_SUB_TASK_VISIBILITY": "Toggle subtask visibility",
|
||||
"TOGGLE_TAGS": "Toggle tags",
|
||||
"TRACK_TIME": "Start tracking time",
|
||||
"TRACK_TIME_STOP": "Pause tracking time",
|
||||
"UNSCHEDULE_TASK": "Unschedule task",
|
||||
"UPDATE_ISSUE_DATA": "Update issue data"
|
||||
},
|
||||
"D_CONFIRM_DELETE": {
|
||||
"MSG": "Dorești să ștergi sarcina \"{{title}}\"?",
|
||||
"OK": "Șterge"
|
||||
"OK": "Delete"
|
||||
},
|
||||
"D_CONFIRM_SHORT_SYNTAX_NEW_TAG": {
|
||||
"MSG": "DDorești să creezi eticheta nouă {{tagsTxt}}?",
|
||||
"OK": "Creează etichetă"
|
||||
"MSG": "Do you want to create the new tag {{tagsTxt}}?",
|
||||
"OK": "Create tag"
|
||||
},
|
||||
"D_CONFIRM_SHORT_SYNTAX_NEW_TAGS": {
|
||||
"MSG": "Dorești să creezi etichetele noi {{tagsTxt}}?",
|
||||
"OK": "Creează etichete"
|
||||
"MSG": "Do you want to create the new tags {{tagsTxt}}?",
|
||||
"OK": "Create tags"
|
||||
},
|
||||
"D_DEADLINE": {
|
||||
"ADD_TIME": "Adaugă ora",
|
||||
|
|
@ -1573,28 +1549,28 @@
|
|||
"SET_DEADLINE": "Setează termen limită"
|
||||
},
|
||||
"D_REMINDER_VIEW": {
|
||||
"ADD_ALL_TO_TODAY": "Adaugă-le pe toate la Astăzi",
|
||||
"ADD_TO_TODAY": "Adaugă la Astăzi",
|
||||
"ADD_ALL_TO_TODAY": "Add all to Today",
|
||||
"ADD_TO_TODAY": "Add to Today",
|
||||
"CLEAR_ALL_REMINDERS": "Șterge toate memento-urile",
|
||||
"CLEAR_REMINDER": "Șterge memento",
|
||||
"COMPLETE": "Finalizează",
|
||||
"COMPLETE_ALL": "Finalizează-le pe toate",
|
||||
"COMPLETE": "Complete",
|
||||
"COMPLETE_ALL": "Complete all",
|
||||
"DEADLINE_REMINDER": "Memento termen limită",
|
||||
"DEADLINE_SECTION": "Termene limită",
|
||||
"DISMISS_ALL_REMINDERS_KEEP_TODAY": "Înlătură toate mementourile (păstrează în Astăzi)",
|
||||
"DISMISS_REMINDER_KEEP_TODAY": "Înlătură memento (păstrează în Astăzi)",
|
||||
"DONE": "Realizat",
|
||||
"DISMISS_ALL_REMINDERS_KEEP_TODAY": "Dismiss All Reminders (Keep in Today)",
|
||||
"DISMISS_REMINDER_KEEP_TODAY": "Dismiss Reminder (Keep in Today)",
|
||||
"DONE": "Done",
|
||||
"DUE_SECTION": "Planificate",
|
||||
"DUE_TASK": "Memento sarcină planificată",
|
||||
"DUE_TASKS": "Memento sarcini planificate",
|
||||
"RESCHEDULE_EDIT": "Editează (planifică din nou)",
|
||||
"RESCHEDULE_UNTIL_TOMORROW": "Planifică din nou pentru mâine",
|
||||
"SNOOZE": "Amână",
|
||||
"SNOOZE_ALL": "Amână-le pe toate",
|
||||
"START": "Începe",
|
||||
"TASK_REMINDERS": "Mementouri sarcini",
|
||||
"UNSCHEDULE": "Șterge planificarea",
|
||||
"UNSCHEDULE_ALL": "Șterge planificarea tuturor"
|
||||
"DUE_TASK": "Reminder for planned task",
|
||||
"DUE_TASKS": "Reminder for planned tasks",
|
||||
"RESCHEDULE_EDIT": "Edit (reschedule)",
|
||||
"RESCHEDULE_UNTIL_TOMORROW": "Reschedule for tomorrow",
|
||||
"SNOOZE": "Snooze",
|
||||
"SNOOZE_ALL": "Snooze all",
|
||||
"START": "Start",
|
||||
"TASK_REMINDERS": "Memento-uri sarcini",
|
||||
"UNSCHEDULE": "Unschedule",
|
||||
"UNSCHEDULE_ALL": "Unschedule all"
|
||||
},
|
||||
"D_SCHEDULE_TASK": {
|
||||
"QA_NEXT_MONTH": "Schedule next month",
|
||||
|
|
@ -1613,9 +1589,9 @@
|
|||
"UNSCHEDULE": "Unschedule"
|
||||
},
|
||||
"D_SELECT_DATE_AND_TIME": {
|
||||
"DATE": "Dată",
|
||||
"TIME": "Oră",
|
||||
"TITLE": "Alege data și ora"
|
||||
"DATE": "Date",
|
||||
"TIME": "Time",
|
||||
"TITLE": "Select Date and Time"
|
||||
},
|
||||
"D_TIME": {
|
||||
"ADD_FOR_OTHER_DAY": "Add time spent for other day",
|
||||
|
|
@ -1695,18 +1671,18 @@
|
|||
"OK": "Remove completely"
|
||||
},
|
||||
"D_CONFIRM_UPDATE_INSTANCES": {
|
||||
"CANCEL": "Doar sarcinile viitoare",
|
||||
"MSG": "Există {{tasksNr}} instanțe create pentru această sarcină recurentă. Dorești să le actualizezi pe toate cu noile valori implicite sau doar să actualizezi sarcinile viitoare?",
|
||||
"OK": "Actualizează toate instanțele"
|
||||
"CANCEL": "Only future tasks",
|
||||
"MSG": "There are {{tasksNr}} instances created for this recurring task. Do you want to update all of them with the new defaults or just future tasks?",
|
||||
"OK": "Update all instances"
|
||||
},
|
||||
"D_SKIP_INSTANCE": {
|
||||
"MSG": "Omiți sarcina recurentă pe {{date}}? Acest lucru va preveni crearea sarcinii doar pe această dată.",
|
||||
"MSG": "Skip the recurring task on {{date}}? This will prevent the task from being created on this date only.",
|
||||
"OK": "Omite"
|
||||
},
|
||||
"D_EDIT": {
|
||||
"ADD": "Adaugă configurație sarcină recurentă",
|
||||
"ADVANCED_CFG": "Configurare avansată",
|
||||
"EDIT": "Editează configurația sarcinii recurente",
|
||||
"ADD": "Add Recurring Task Config",
|
||||
"ADVANCED_CFG": "Advanced configuration",
|
||||
"EDIT": "Edit Recurring Task Config",
|
||||
"HEATMAP_LABEL": "Activitate",
|
||||
"TAG_LABEL": "Etichete",
|
||||
"TIME_SPENT_THIS_MONTH": "Luna aceasta",
|
||||
|
|
@ -1714,105 +1690,105 @@
|
|||
"TIME_SPENT_TOTAL": "Total"
|
||||
},
|
||||
"F": {
|
||||
"C_DAY": "Zi",
|
||||
"C_MONTH": "Lună",
|
||||
"C_WEEK": "Săptămână",
|
||||
"C_YEAR": "An",
|
||||
"DEFAULT_ESTIMATE": "Estimare implicită",
|
||||
"DISABLE_AUTO_UPDATE_SUBTASKS": "Dezactivează auto-actualizarea subsarcinilor",
|
||||
"DISABLE_AUTO_UPDATE_SUBTASKS_DESCRIPTION": "Nu actualiza automat subsarcinile moștenate când instanța cea mai nouă se modifică",
|
||||
"FRIDAY": "vineri",
|
||||
"INHERIT_SUBTASKS": "Moștenește subsarcini",
|
||||
"INHERIT_SUBTASKS_DESCRIPTION": "Când este activat, subsarcinile din instanța cea mai recentă a sarcinii vor fi recreate cu sarcina recurentă",
|
||||
"MONDAY": "luni",
|
||||
"NOTES": "Notițe implicite",
|
||||
"Q_CUSTOM": "Configurare recurentă personalizată",
|
||||
"Q_DAILY": "În fiecare zi",
|
||||
"Q_MONDAY_TO_FRIDAY": "În fiecare zi de luni până vineri",
|
||||
"Q_MONTHLY_CURRENT_DATE": "În fiecare lună în ziua {{dateDayStr}}",
|
||||
"C_DAY": "Day",
|
||||
"C_MONTH": "Month",
|
||||
"C_WEEK": "Week",
|
||||
"C_YEAR": "Year",
|
||||
"DEFAULT_ESTIMATE": "Default Estimate",
|
||||
"DISABLE_AUTO_UPDATE_SUBTASKS": "Disable auto-updating subtasks",
|
||||
"DISABLE_AUTO_UPDATE_SUBTASKS_DESCRIPTION": "Do not update inherited subtasks automatically when the newest instance changes",
|
||||
"FRIDAY": "Friday",
|
||||
"INHERIT_SUBTASKS": "Inherit subtasks",
|
||||
"INHERIT_SUBTASKS_DESCRIPTION": "When enabled, subtasks from the most recent task instance will be recreated with the recurring task",
|
||||
"MONDAY": "Monday",
|
||||
"NOTES": "Default notes",
|
||||
"Q_CUSTOM": "Custom recurring config",
|
||||
"Q_DAILY": "Every day",
|
||||
"Q_MONDAY_TO_FRIDAY": "Every Monday through Friday",
|
||||
"Q_MONTHLY_CURRENT_DATE": "Every month on the day {{dateDayStr}}",
|
||||
"Q_MONTHLY_FIRST_DAY": "În fiecare lună în prima zi",
|
||||
"Q_MONTHLY_LAST_DAY": "În fiecare lună în ultima zi",
|
||||
"Q_WEEKLY_CURRENT_WEEKDAY": "În fiecare săptămână {{weekdayStr}}",
|
||||
"Q_YEARLY_CURRENT_DATE": "În fiecare an pe {{dayAndMonthStr}}",
|
||||
"QUICK_SETTING": "Configurație recurentă",
|
||||
"REMIND_AT": "Amintește la",
|
||||
"REMIND_AT_PLACEHOLDER": "Selectează când să fie amintit",
|
||||
"SKIP_FOR_DATE": "Omite pentru {{date}}",
|
||||
"SKIP_INSTANCE": "Omite pentru astăzi",
|
||||
"REPEAT_CYCLE": "Ciclu de repetare",
|
||||
"REPEAT_EVERY": "Repetă la fiecare",
|
||||
"SATURDAY": "sâmbătă",
|
||||
"SCHEDULE_TYPE_LABEL": "Tip programare",
|
||||
"Q_WEEKLY_CURRENT_WEEKDAY": "Every week on {{weekdayStr}}",
|
||||
"Q_YEARLY_CURRENT_DATE": "Every year on the {{dayAndMonthStr}}",
|
||||
"QUICK_SETTING": "Recurring Config",
|
||||
"REMIND_AT": "Remind at",
|
||||
"REMIND_AT_PLACEHOLDER": "Select when to remind",
|
||||
"SKIP_FOR_DATE": "Skip for {{date}}",
|
||||
"SKIP_INSTANCE": "Skip for today",
|
||||
"REPEAT_CYCLE": "Recur cycle",
|
||||
"REPEAT_EVERY": "Recur every",
|
||||
"SATURDAY": "Saturday",
|
||||
"SCHEDULE_TYPE_LABEL": "Schedule type",
|
||||
"SKIP_OVERDUE": "Omite instanțele întârziate",
|
||||
"SKIP_OVERDUE_DESCRIPTION": "Când este activat, dacă aplicația este deschisă după ce a fost omisă o apariție programată (de ex. o sarcină săptămânală în care azi nu este o zi programată), instanța ratată va fi omisă silențios în loc să fie creată ca sarcină întârziată",
|
||||
"START_DATE": "Dată de început",
|
||||
"START_TIME": "Timpul programat de început",
|
||||
"START_TIME_DESCRIPTION": "De ex. 15:00. Lasă necompletat pentru o sarcină pe toată ziua",
|
||||
"SUNDAY": "duminică",
|
||||
"THURSDAY": "joi",
|
||||
"TITLE": "Nume sarcină",
|
||||
"TUESDAY": "marți",
|
||||
"WEDNESDAY": "miercuri"
|
||||
"START_DATE": "Start date",
|
||||
"START_TIME": "Scheduled start time",
|
||||
"START_TIME_DESCRIPTION": "E.g. 15:00. Leave blank for an all day task",
|
||||
"SUNDAY": "Sunday",
|
||||
"THURSDAY": "Thursday",
|
||||
"TITLE": "Task title",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday"
|
||||
},
|
||||
"SNACK_REPEAT_DIALOG_FAIL": "Nu s-a putut deschide dialogul de configurare repetare. Puteți configura din meniul sarcinii."
|
||||
},
|
||||
"TASK_VIEW": {
|
||||
"CUSTOMIZER": {
|
||||
"DEADLINE_NEXT_MONTH": "Scadente luna viitoare",
|
||||
"DEADLINE_NEXT_WEEK": "Scadente săptămâna viitoare",
|
||||
"DEADLINE_THIS_MONTH": "Scadente luna aceasta",
|
||||
"DEADLINE_THIS_WEEK": "Scadente săptămâna aceasta",
|
||||
"DEADLINE_TODAY": "Scadente astăzi",
|
||||
"DEADLINE_TOMORROW": "Scadente mâine",
|
||||
"ENTER_PROJECT": "Filtrează proiecte",
|
||||
"ENTER_TAG": "Filtrează etichete",
|
||||
"ESTIMATED_TIME": "Timp estimat",
|
||||
"FILTER_BY": "Filtrează după",
|
||||
"FILTER_DEADLINE": "Scadență",
|
||||
"FILTER_DEFAULT": "Fără filtru",
|
||||
"FILTER_ESTIMATED_TIME": "Timp estimat",
|
||||
"FILTER_NOT_SPECIFIED": "Nu este specificat",
|
||||
"FILTER_PROJECT": "Proiect",
|
||||
"FILTER_SCHEDULED_DATE": "Dată programată",
|
||||
"FILTER_TAG": "Etichetă",
|
||||
"FILTER_TIME_SPENT": "Timp petrecut",
|
||||
"GROUP_BY": "Grupare după",
|
||||
"GROUP_DEADLINE": "Scadență",
|
||||
"GROUP_DEADLINE_NONE": "Fără scadență",
|
||||
"GROUP_DEFAULT": "Fără grup",
|
||||
"GROUP_PROJECT": "Proiect",
|
||||
"GROUP_SCHEDULED_DATE": "Dată programată",
|
||||
"GROUP_TAG": "Etichetă",
|
||||
"RESET_ALL": "Resetează tot",
|
||||
"DEADLINE_NEXT_MONTH": "Due Next Month",
|
||||
"DEADLINE_NEXT_WEEK": "Due Next Week",
|
||||
"DEADLINE_THIS_MONTH": "Due This Month",
|
||||
"DEADLINE_THIS_WEEK": "Due This Week",
|
||||
"DEADLINE_TODAY": "Due Today",
|
||||
"DEADLINE_TOMORROW": "Due Tomorrow",
|
||||
"ENTER_PROJECT": "Filter Projects",
|
||||
"ENTER_TAG": "Filter Tag",
|
||||
"ESTIMATED_TIME": "Estimated Time",
|
||||
"FILTER_BY": "Filter By",
|
||||
"FILTER_DEADLINE": "Deadline",
|
||||
"FILTER_DEFAULT": "No Filter",
|
||||
"FILTER_ESTIMATED_TIME": "Estimated Time",
|
||||
"FILTER_NOT_SPECIFIED": "Not specified",
|
||||
"FILTER_PROJECT": "Project",
|
||||
"FILTER_SCHEDULED_DATE": "Scheduled Date",
|
||||
"FILTER_TAG": "Tag",
|
||||
"FILTER_TIME_SPENT": "Time Spent",
|
||||
"GROUP_BY": "Group By",
|
||||
"GROUP_DEADLINE": "Deadline",
|
||||
"GROUP_DEADLINE_NONE": "No deadline",
|
||||
"GROUP_DEFAULT": "No Group",
|
||||
"GROUP_PROJECT": "Project",
|
||||
"GROUP_SCHEDULED_DATE": "Scheduled Date",
|
||||
"GROUP_TAG": "Tag",
|
||||
"RESET_ALL": "Reset All",
|
||||
"SAVE_SORT": "Salvează ca implicit",
|
||||
"SCHEDULED_NEXT_MONTH": "Luna viitoare",
|
||||
"SCHEDULED_NEXT_WEEK": "Săptămâna viitoare",
|
||||
"SCHEDULED_THIS_MONTH": "Luna aceasta",
|
||||
"SCHEDULED_THIS_WEEK": "Săptămâna aceasta",
|
||||
"SCHEDULED_NEXT_MONTH": "Next Month",
|
||||
"SCHEDULED_NEXT_WEEK": "Next Week",
|
||||
"SCHEDULED_THIS_MONTH": "This Month",
|
||||
"SCHEDULED_THIS_WEEK": "This Week",
|
||||
"SCHEDULED_TODAY": "Astăzi",
|
||||
"SCHEDULED_TOMORROW": "Mâine",
|
||||
"SORT": "Sortare",
|
||||
"SORT_CREATION_DATE": "Dată de creare",
|
||||
"SORT_DEADLINE": "Scadență",
|
||||
"SORT_DEFAULT": "Implicit",
|
||||
"SORT_NAME": "Nume",
|
||||
"SORT_SCHEDULED_DATE": "Dată programată",
|
||||
"TIME_1HOUR": "> 1 oră",
|
||||
"TIME_2HOUR": "> 2 ore",
|
||||
"TIME_10MIN": "> 10 minute",
|
||||
"TIME_30MIN": "> 30 minute",
|
||||
"TIME_SPENT": "Timp petrecut"
|
||||
"SORT_CREATION_DATE": "Creation Date",
|
||||
"SORT_DEADLINE": "Deadline",
|
||||
"SORT_DEFAULT": "Default",
|
||||
"SORT_NAME": "Name",
|
||||
"SORT_SCHEDULED_DATE": "Scheduled Date",
|
||||
"TIME_1HOUR": "> 1 Hour",
|
||||
"TIME_2HOUR": "> 2 Hours",
|
||||
"TIME_10MIN": "> 10 Minutes",
|
||||
"TIME_30MIN": "> 30 Minutes",
|
||||
"TIME_SPENT": "Time Spent"
|
||||
}
|
||||
},
|
||||
"TIME_TRACKING": {
|
||||
"B": {
|
||||
"ALREADY_DID": "Deja am făcut",
|
||||
"SNOOZE": "Amână {{time}}"
|
||||
"ALREADY_DID": "I already did",
|
||||
"SNOOZE": "Snooze {{time}}"
|
||||
},
|
||||
"B_TTR": {
|
||||
"ADD_TO_TASK": "Adaugă la sarcină",
|
||||
"MSG": "Nu ai ținut evidența timpului pentru {{time}}",
|
||||
"MSG_WITHOUT_TIME": "Nu ai ținut evidența timpului"
|
||||
"ADD_TO_TASK": "Add to Task",
|
||||
"MSG": "You have not been tracking time for {{time}}",
|
||||
"MSG_WITHOUT_TIME": "You have not been tracking time"
|
||||
},
|
||||
"D_ARCHIVE_COMPRESS": {
|
||||
"BTN_COMPRESS": "Comprimă arhiva",
|
||||
|
|
@ -1952,10 +1928,10 @@
|
|||
"DO_IT": "Fă-l!",
|
||||
"DONT_SHOW_AGAIN": "Nu mai afișa din nou",
|
||||
"DUPLICATE": "Duplică",
|
||||
"DURATION_DESCRIPTION": "d.e. \"5h 23m\" care rezultă în 5 ore și 23 minute",
|
||||
"DURATION_DESCRIPTION": "ex: \"5h 23m\" care rezultă în 5 ore și 23 minute",
|
||||
"EDIT": "Editează",
|
||||
"ENABLED": "Activat",
|
||||
"EXAMPLE_VAL": "d.e. 32m",
|
||||
"EXAMPLE_VAL": "ex: 32m",
|
||||
"EXTENSION_INFO": "Te rog <a target=\"_blank\" href=\"https://chrome.google.com/webstore/detail/super-productivity/ljkbjodfmekklcoibdnhahlaalhihmlb\"> descarcă extensia Chrome</a> pentru a permite comunicarea cu API-ul Jira și tratarea timpului mort. Acest lucru nu funcționează pe mobil. <strong>Fără extensie, aceste funcții nu vor funcționa!</strong>",
|
||||
"HIDE": "Ascunde",
|
||||
"ICON_INP_DESCRIPTION": "Toate emoji-urile UTF-8 sunt de asemenea suportate!",
|
||||
|
|
@ -1966,7 +1942,7 @@
|
|||
"NEXT": "Următorul",
|
||||
"NO_CON": "Momentan ești offline. Te rog reconectează-te la internet.",
|
||||
"NONE": "Niciunul",
|
||||
"OK": "OK",
|
||||
"OK": "Ok",
|
||||
"OPEN_IN_BROWSER": "Deschide în navigatorul web",
|
||||
"OVERDUE": "Restante",
|
||||
"PASSWORD_STRENGTH_FAIR": "Acceptabilă",
|
||||
|
|
@ -1986,27 +1962,25 @@
|
|||
},
|
||||
"GCF": {
|
||||
"APP_FEATURES": {
|
||||
"BOARDS": "Table",
|
||||
"DONATE_PAGE": "Pagină de donații",
|
||||
"BOARDS": "Boards",
|
||||
"DONATE_PAGE": "Donate page",
|
||||
"FINISH_DAY": "Încheie ziua",
|
||||
"FOCUS_MODE": "Mod concentrare",
|
||||
"FOCUS_MODE": "Focus mode",
|
||||
"HABITS": "Obiceiuri",
|
||||
"HELP": "Activează sau dezactivează anumite funcții ale aplicației din interfață.",
|
||||
"ISSUES_PANEL": "Panou probleme",
|
||||
"PLANNER": "Planificator",
|
||||
"PROJECT_NOTES": "Notițe proiect",
|
||||
"HELP": "Enable or disable specific app features across the UI.",
|
||||
"ISSUES_PANEL": "Issues panel",
|
||||
"PLANNER": "Planner",
|
||||
"PROJECT_NOTES": "Project notes",
|
||||
"SCHEDULE": "Orar",
|
||||
"SCHEDULE_DAY_PANEL": "Panou programare zi",
|
||||
"SCHEDULE_DAY_PANEL": "Schedule day panel",
|
||||
"SEARCH": "Caută",
|
||||
"SYNC_BUTTON": "Buton sincronizare",
|
||||
"TIME_TRACKING": "Cronometru urmărire timp",
|
||||
"TITLE": "Funcții aplicație",
|
||||
"USER_PROFILES": "Profiluri utilizator (experimental — ar putea fi eliminate)",
|
||||
"USER_PROFILES_HINT": "Îți permite să creezi și să comuti între diferite profiluri de utilizator, fiecare cu setări, sarcini și configurații de sincronizare separate. Butonul de gestionare a profilurilor va apărea în colțul din dreapta sus când este activat. Notă: Dezactivarea acestei funcții va ascunde interfața dar va păstra datele profilului. (Funcție experimentală. Fără garanții. Asigură-te că ai o copie de rezervă!)",
|
||||
"USER_PROFILES_WARNING": "<strong>Funcție experimentală:</strong> Această funcție este încă în dezvoltare și ar putea fi eliminată sau modificată semnificativ într-o actualizare viitoare. Folosește cu grijă și asigură-te că ai copii de rezervă ale datelor tale.",
|
||||
"EXPERIMENTAL_WARNING_TITLE": "Feature Experimental",
|
||||
"EXPERIMENTAL_WARNING_MSG": "Această funcție este experimentală. Poate conține erori, poate fi modificată semnificativ sau poate fi eliminată într-o actualizare viitoare. Asigură-te că ai copii de rezervă ale datelor tale.<br/><br/>Vrei să o activezi?"
|
||||
},
|
||||
"SYNC_BUTTON": "Sync button",
|
||||
"TIME_TRACKING": "Stopwatch time tracking",
|
||||
"TITLE": "App Features",
|
||||
"USER_PROFILES": "User profiles (experimental)",
|
||||
"USER_PROFILES_HINT": "Allows you to create and switch between different user profiles, each with separate settings, tasks, and sync configurations. The profile management button will appear in the top-right corner when enabled. Note: Disabling this feature will hide the UI but preserve your profile data. (Experimental feature. No guarantees. Make sure to have a backup!)",
|
||||
"USER_PROFILES_WARNING": "<strong>Funcție experimentală:</strong> Această funcție este încă în dezvoltare și poate fi eliminată sau modificată semnificativ într-o actualizare viitoare. Folosește pe propriul risc și asigură-te că ai backup-uri ale datelor."
|
||||
},
|
||||
"AUTO_BACKUPS": {
|
||||
"HELP": "Automatically save all data to your app folder to have it ready if something goes wrong.",
|
||||
"LABEL_IS_ENABLED": "Enable automatic backups",
|
||||
|
|
@ -2021,8 +1995,8 @@
|
|||
"SHOW_BANNER_THRESHOLD": "Show a notification X before the event (blank for disabled)"
|
||||
},
|
||||
"CLIPBOARD_IMAGES": {
|
||||
"DELETE": "Șterge imaginea",
|
||||
"DELETE_ALL": "Șterge-le pe toate",
|
||||
"DELETE": "Delete image",
|
||||
"DELETE_ALL": "Delete All",
|
||||
"DELETE_ALL_SUCCESS": "Toate imaginile din clipboard șterse cu succes",
|
||||
"DELETE_SUCCESS": "Imagine din clipboard ștearsă cu succes",
|
||||
"ERROR_DELETING": "Ștergerea imaginii din clipboard a eșuat",
|
||||
|
|
@ -2039,14 +2013,14 @@
|
|||
"PATH_LABEL": "Cale stocare imagini",
|
||||
"PATH_PLACEHOLDER": "Lasă gol pentru locația implicită",
|
||||
"PATH_TITLE": "Locație stocare",
|
||||
"SELECT_PATH": "Selectează dosarul",
|
||||
"SELECT_PATH": "Select Folder",
|
||||
"SELECT_PATH_TITLE": "Selectează dosarul pentru imagini clipboard",
|
||||
"SORT_BY": "Sortează după",
|
||||
"SORT_LARGEST": "Cele mai mari primele",
|
||||
"SORT_NEWEST": "Cele mai noi primele",
|
||||
"SORT_OLDEST": "Cele mai vechi primele",
|
||||
"SORT_SMALLEST": "Cele mai mici primele",
|
||||
"TITLE": "Imagini Clipboard",
|
||||
"SORT_BY": "Sort by",
|
||||
"SORT_LARGEST": "Largest first",
|
||||
"SORT_NEWEST": "Newest first",
|
||||
"SORT_OLDEST": "Oldest first",
|
||||
"SORT_SMALLEST": "Smallest first",
|
||||
"TITLE": "Imagini clipboard",
|
||||
"TOTAL_IMAGES": "Total imagini",
|
||||
"TOTAL_SIZE": "Dimensiune totală"
|
||||
},
|
||||
|
|
@ -2175,7 +2149,6 @@
|
|||
"TIME_LOCALE_IT_IT": "Italienesc: 24 de ore, DD/MM/YYYY",
|
||||
"TIME_LOCALE_JA_JP": "Japonez: 24 de ore, YYYY/MM/DD",
|
||||
"TIME_LOCALE_KO_KR": "Korean: 12 ore AM/PM, YYYY. MM. DD",
|
||||
"TIME_LOCALE_PL_PL": "Polonez: 24 de ore, DD.MM.YYYY",
|
||||
"TIME_LOCALE_PT_BR": "Portughez (Brazilia): 24 de ore, DD/MM/YYYY",
|
||||
"TIME_LOCALE_RO_MD": "Românesc (Moldova): 24 de ore, DD.MM.YYYY",
|
||||
"TIME_LOCALE_RO_RO": "Românesc: 24 de ore, DD.MM.YYYY",
|
||||
|
|
@ -2245,10 +2218,10 @@
|
|||
"L_LUNCH_BREAK_START": "Lunch break start",
|
||||
"L_WORK_END": "Work Day End",
|
||||
"L_WORK_START": "Work Day Start",
|
||||
"LUNCH_BREAK_START_END_DESCRIPTION": "d.e. 13:00",
|
||||
"LUNCH_BREAK_START_END_DESCRIPTION": "ex: 13:00",
|
||||
"MONTH": "Lună",
|
||||
"TITLE": "Programare",
|
||||
"WORK_START_END_DESCRIPTION": "d.e. 17:00"
|
||||
"WORK_START_END_DESCRIPTION": "ex: 17:00"
|
||||
},
|
||||
"SHORT_SYNTAX": {
|
||||
"HELP": "<p>Here you can control short syntax options when creating a task</p>",
|
||||
|
|
@ -2262,9 +2235,9 @@
|
|||
"URL_BEHAVIOR_LABEL": "URL behavior when adding tasks"
|
||||
},
|
||||
"SOUND": {
|
||||
"BREAK_REMINDER_SOUND": "Sunet memento de luat o pauză",
|
||||
"DONE_SOUND": "Sunet sarcină finalizată",
|
||||
"IS_INCREASE_DONE_PITCH": "Crește tonul pentru fiecare sarcină finalizată",
|
||||
"BREAK_REMINDER_SOUND": "Take a break reminder sound",
|
||||
"DONE_SOUND": "Task completed sound",
|
||||
"IS_INCREASE_DONE_PITCH": "Increase pitch for every task completed",
|
||||
"TITLE": "Sunet",
|
||||
"TRACK_TIME_SOUND": "Sunet memento de urmărire a timpului",
|
||||
"VOLUME": "Volum"
|
||||
|
|
@ -2673,19 +2646,18 @@
|
|||
"CREATE_NEW_PROFILE": "Creează un nou profil",
|
||||
"CREATED": "Creat:",
|
||||
"DELETE_PROFILE": "Șterge profilul",
|
||||
"DEPRECATION_WARNING": "Funcția experimentală Profile utilizator va fi eliminată într-o actualizare viitoare. Te rog să îți exporti datele de profil ca backup.",
|
||||
"DIALOG_TITLE": "Administrează profilurile de utilizator",
|
||||
"EXISTING_PROFILES": "Profiluri existente",
|
||||
"EXPORT_PROFILE": "Exportă profilul",
|
||||
"MANAGE_PROFILES": "Gestionează profilurile...",
|
||||
"PROFILE_NAME": "Nume profil",
|
||||
"PROFILE_NAME_PLACEHOLDER": "d.e. Muncă, Personal",
|
||||
"PROFILE_NAME_PLACEHOLDER": "ex: Muncă, Personal",
|
||||
"RENAME": "Redenumește",
|
||||
"SAVE": "Salvează"
|
||||
},
|
||||
"V": {
|
||||
"E_DATETIME": "Valoare introdusă nu este o dată și oră!",
|
||||
"E_DURATION": "Te rog introdu o durată validă (d.e. 1h)",
|
||||
"E_DURATION": "Te rog introdu o durată validă (ex: 1h)",
|
||||
"E_MAX": "Nu trebuie să fie mai mare decât {{val}}",
|
||||
"E_MAX_LENGTH": "Trebuie să aibă cel mult {{val}} caractere",
|
||||
"E_MIN": "Nu trebuie să fie mai mică decât {{val}}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue