mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
Merge origin/master into task/issue-8228-68b7fe
Resolve conflict in electron/local-file-sync.ts by keeping the PR side: master's assertPathOutside guard was added inside the TO_FILE_URL handler, which this PR deletes entirely (deletion supersedes the guard). Also drop master's two new TO_FILE_URL tests (the handler no longer exists).
This commit is contained in:
commit
789214640d
134 changed files with 4410 additions and 640 deletions
|
|
@ -58,9 +58,9 @@ class FocusModeForegroundService : Service() {
|
|||
// recents). Mirrors TrackingForegroundService's static-state pattern so
|
||||
// a focus session can be recovered into the Angular store (#7855).
|
||||
// `remainingMs` and `lastUpdateTimestamp` cannot use `private set`
|
||||
// because the tick Runnable (a nested anonymous object) mutates them;
|
||||
// the other three are written only from instance methods, so they keep
|
||||
// `private set`.
|
||||
// because the completion Runnable (a nested anonymous object) mutates
|
||||
// them; the other three are written only from instance methods, so they
|
||||
// keep `private set`.
|
||||
@Volatile
|
||||
var durationMs: Long = 0
|
||||
private set
|
||||
|
|
@ -81,8 +81,10 @@ class FocusModeForegroundService : Service() {
|
|||
|
||||
/**
|
||||
* Live remaining time (countdown) or elapsed time (Flowtime, where
|
||||
* durationMs is 0 and remainingMs accumulates). Accounts for the time
|
||||
* since the last 1-second tick so a cold-start read stays accurate.
|
||||
* durationMs is 0 and remainingMs accumulates). The snapshot fields only
|
||||
* move on start/update/completion, so this derives the live value from
|
||||
* the wall clock — it is THE time source for the notification, the
|
||||
* completion scheduling, and the JS readback (#8243).
|
||||
*
|
||||
* Named `liveRemainingMs` rather than `getRemainingMs` to avoid a JVM
|
||||
* signature clash with the `remainingMs` property's generated getter.
|
||||
|
|
@ -105,32 +107,32 @@ class FocusModeForegroundService : Service() {
|
|||
private var hasNotifiedCompletion: Boolean = false
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val updateRunnable = object : Runnable {
|
||||
|
||||
// Fires once at the expected countdown end instead of ticking every second —
|
||||
// the notification chronometer renders the live timer without app work (#8243).
|
||||
// Handler delays run on uptime, which stalls in deep sleep, so the runnable
|
||||
// can only fire at wall-clock >= the requested delay (late completion in
|
||||
// Doze, same as the old 1s loop). The re-arm branch below therefore only
|
||||
// triggers when the wall clock moved BACKWARD (manual change/NTP) — re-arming
|
||||
// keeps completion consistent with the wall-clock-based chronometer.
|
||||
private val completionRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (isRunning && !isPaused) {
|
||||
// Update remaining time (countdown mode)
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastUpdateTimestamp
|
||||
lastUpdateTimestamp = now
|
||||
|
||||
if (durationMs > 0) {
|
||||
// Countdown mode: decrease remaining time
|
||||
val previousRemaining = remainingMs
|
||||
remainingMs = (remainingMs - elapsed).coerceAtLeast(0)
|
||||
|
||||
// Check for timer completion (only in countdown mode, not Flowtime)
|
||||
if (remainingMs == 0L && previousRemaining > 0L && !hasNotifiedCompletion) {
|
||||
onTimerComplete()
|
||||
return // Stop the runnable, timer is done
|
||||
}
|
||||
} else {
|
||||
// Flowtime mode: increase elapsed time (remainingMs is actually elapsed)
|
||||
remainingMs += elapsed
|
||||
}
|
||||
|
||||
updateNotification()
|
||||
handler.postDelayed(this, 1000)
|
||||
if (!isRunning || isPaused || durationMs <= 0 || hasNotifiedCompletion) return
|
||||
val remaining = liveRemainingMs()
|
||||
if (remaining > 0) {
|
||||
handler.postDelayed(this, remaining)
|
||||
return
|
||||
}
|
||||
remainingMs = 0
|
||||
lastUpdateTimestamp = System.currentTimeMillis()
|
||||
onTimerComplete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleCompletionCheck() {
|
||||
handler.removeCallbacks(completionRunnable)
|
||||
if (isRunning && !isPaused && durationMs > 0) {
|
||||
handler.postDelayed(completionRunnable, liveRemainingMs())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,6 +161,10 @@ class FocusModeForegroundService : Service() {
|
|||
title = intent.getStringExtra(EXTRA_TITLE) ?: "Focus"
|
||||
taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE)
|
||||
durationMs = intent.getLongExtra(EXTRA_DURATION_MS, 0L)
|
||||
// Anchor before remainingMs: a torn liveRemainingMs() read from
|
||||
// the JS bridge thread then returns a slightly stale value
|
||||
// instead of subtracting the whole since-last-anchor gap.
|
||||
lastUpdateTimestamp = System.currentTimeMillis()
|
||||
remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, 0L)
|
||||
isBreak = intent.getBooleanExtra(EXTRA_IS_BREAK, false)
|
||||
isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, false)
|
||||
|
|
@ -176,22 +182,18 @@ class FocusModeForegroundService : Service() {
|
|||
stopForegroundAndSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val wasPaused = isPaused
|
||||
title = intent.getStringExtra(EXTRA_TITLE) ?: title
|
||||
remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, remainingMs)
|
||||
// Defensive fallback only — the sole caller (JavaScriptInterface.
|
||||
// updateFocusModeService) always sends the extra. Anchor before
|
||||
// remainingMs (see ACTION_START) to bias torn reads safe.
|
||||
val newRemainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, liveRemainingMs())
|
||||
lastUpdateTimestamp = System.currentTimeMillis()
|
||||
remainingMs = newRemainingMs
|
||||
isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, isPaused)
|
||||
isBreak = intent.getBooleanExtra(EXTRA_IS_BREAK, isBreak)
|
||||
taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE) ?: taskTitle
|
||||
lastUpdateTimestamp = System.currentTimeMillis()
|
||||
|
||||
// Restart update runnable if resuming from paused state
|
||||
if (wasPaused && !isPaused) {
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
handler.post(updateRunnable)
|
||||
} else if (!wasPaused && isPaused) {
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
}
|
||||
|
||||
scheduleCompletionCheck()
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
|
|
@ -220,9 +222,10 @@ class FocusModeForegroundService : Service() {
|
|||
this,
|
||||
title,
|
||||
taskTitle,
|
||||
remainingMs,
|
||||
isPaused,
|
||||
isBreak
|
||||
remainingMs = liveRemainingMs(),
|
||||
isCountdown = durationMs > 0,
|
||||
isPaused = isPaused,
|
||||
isBreak = isBreak
|
||||
)
|
||||
} else {
|
||||
// A content title is required on some OEM skins (notably Samsung
|
||||
|
|
@ -262,11 +265,7 @@ class FocusModeForegroundService : Service() {
|
|||
return false
|
||||
}
|
||||
|
||||
// Start update loop if not paused
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
if (!isPaused) {
|
||||
handler.post(updateRunnable)
|
||||
}
|
||||
scheduleCompletionCheck()
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -277,7 +276,7 @@ class FocusModeForegroundService : Service() {
|
|||
|
||||
private fun stopAfterForegroundFailure(startId: Int) {
|
||||
isRunning = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
handler.removeCallbacks(completionRunnable)
|
||||
title = ""
|
||||
taskTitle = null
|
||||
durationMs = 0
|
||||
|
|
@ -301,7 +300,7 @@ class FocusModeForegroundService : Service() {
|
|||
Log.d(TAG, "Stopping focus mode")
|
||||
|
||||
isRunning = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
handler.removeCallbacks(completionRunnable)
|
||||
|
||||
// Clear the mirrored state so a stale session can't be recovered after
|
||||
// it has legitimately ended (#7855).
|
||||
|
|
@ -323,9 +322,10 @@ class FocusModeForegroundService : Service() {
|
|||
this,
|
||||
title,
|
||||
taskTitle,
|
||||
remainingMs,
|
||||
isPaused,
|
||||
isBreak
|
||||
remainingMs = liveRemainingMs(),
|
||||
isCountdown = durationMs > 0,
|
||||
isPaused = isPaused,
|
||||
isBreak = isBreak
|
||||
)
|
||||
NotificationManagerCompat.from(this).notify(
|
||||
FocusModeNotificationHelper.NOTIFICATION_ID,
|
||||
|
|
@ -376,7 +376,7 @@ class FocusModeForegroundService : Service() {
|
|||
// before onStartCommand cleared it, drop the stale flag so the next cold
|
||||
// stop uses stopService() rather than needlessly re-spawning the service.
|
||||
clearStartPending()
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
handler.removeCallbacks(completionRunnable)
|
||||
}
|
||||
|
||||
// Do not override onTaskRemoved — foreground service must survive app swipe (#7818).
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ object FocusModeNotificationHelper {
|
|||
title: String,
|
||||
taskTitle: String?,
|
||||
remainingMs: Long,
|
||||
isCountdown: Boolean,
|
||||
isPaused: Boolean,
|
||||
isBreak: Boolean
|
||||
): Notification {
|
||||
|
|
@ -58,7 +59,6 @@ object FocusModeNotificationHelper {
|
|||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_sp)
|
||||
.setContentTitle(buildTitle(title, taskTitle))
|
||||
.setContentText(formatDuration(remainingMs))
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
|
|
@ -66,6 +66,25 @@ object FocusModeNotificationHelper {
|
|||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
|
||||
// SystemUI's chronometer renders the ticking timer so the service never
|
||||
// re-posts the notification each second — the old 1 Hz notify() loop was
|
||||
// a steady battery drain (#8243). Paused sessions show static text.
|
||||
if (isPaused) {
|
||||
builder.setContentText(formatDuration(remainingMs))
|
||||
} else if (isCountdown) {
|
||||
builder
|
||||
.setWhen(System.currentTimeMillis() + remainingMs)
|
||||
.setShowWhen(true)
|
||||
.setUsesChronometer(true)
|
||||
.setChronometerCountDown(true)
|
||||
} else {
|
||||
// Flowtime: remainingMs holds the elapsed time, counting up
|
||||
builder
|
||||
.setWhen(System.currentTimeMillis() - remainingMs)
|
||||
.setShowWhen(true)
|
||||
.setUsesChronometer(true)
|
||||
}
|
||||
|
||||
// Add Pause/Resume action
|
||||
if (isPaused) {
|
||||
val resumeIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ package com.superproductivity.superproductivity.service
|
|||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
|
|
@ -71,16 +69,6 @@ class TrackingForegroundService : Service() {
|
|||
|
||||
private var taskTitle: String = ""
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val updateRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (isTracking) {
|
||||
updateNotification()
|
||||
handler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Service created")
|
||||
|
|
@ -187,7 +175,6 @@ class TrackingForegroundService : Service() {
|
|||
|
||||
private fun stopAfterForegroundFailure(startId: Int) {
|
||||
isTracking = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
currentTaskId = null
|
||||
startTimestamp = 0
|
||||
accumulatedMs = 0
|
||||
|
|
@ -208,21 +195,20 @@ class TrackingForegroundService : Service() {
|
|||
|
||||
currentTaskId = taskId
|
||||
taskTitle = title
|
||||
accumulatedMs = timeSpentMs
|
||||
// Anchor first, accumulated second: a torn getElapsedMs() read from the
|
||||
// JS bridge thread then under-reports (caught by the negative-duration
|
||||
// keep-app-value path) instead of double-counting the since-last-anchor
|
||||
// gap — which can be hours now that nothing re-anchors every second.
|
||||
startTimestamp = System.currentTimeMillis()
|
||||
accumulatedMs = timeSpentMs
|
||||
isTracking = true
|
||||
|
||||
// The foreground-service start token was already satisfied at the top
|
||||
// of onStartCommand(). Replace the placeholder notification without
|
||||
// risking a second startForeground() failure resetting tracking state.
|
||||
if (!updateNotification()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Start update loop
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
handler.post(updateRunnable)
|
||||
return true
|
||||
// The chronometer in the notification ticks on its own — no update
|
||||
// loop needed (#8243).
|
||||
return updateNotification()
|
||||
}
|
||||
|
||||
private fun updateTimeSpent(timeSpentMs: Long) {
|
||||
|
|
@ -232,9 +218,10 @@ class TrackingForegroundService : Service() {
|
|||
}
|
||||
Log.d(TAG, "Updating time spent: timeSpentMs=$timeSpentMs (was accumulated=$accumulatedMs)")
|
||||
|
||||
// Reset the timer with the new accumulated value
|
||||
accumulatedMs = timeSpentMs
|
||||
// Reset the timer with the new accumulated value. Anchor first (see
|
||||
// startTracking) so a torn bridge-thread read errs toward under-reporting.
|
||||
startTimestamp = System.currentTimeMillis()
|
||||
accumulatedMs = timeSpentMs
|
||||
|
||||
// Update notification immediately
|
||||
updateNotification()
|
||||
|
|
@ -244,7 +231,6 @@ class TrackingForegroundService : Service() {
|
|||
Log.d(TAG, "Stopping tracking, elapsed=${getElapsedMs()}ms")
|
||||
|
||||
isTracking = false
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
|
||||
// Reset state
|
||||
currentTaskId = null
|
||||
|
|
@ -285,7 +271,6 @@ class TrackingForegroundService : Service() {
|
|||
// before onStartCommand cleared it, drop the stale flag so the next cold
|
||||
// stop uses stopService() rather than needlessly re-spawning the service.
|
||||
clearStartPending()
|
||||
handler.removeCallbacks(updateRunnable)
|
||||
}
|
||||
|
||||
// Do not override onTaskRemoved — foreground service must survive app swipe (#7818).
|
||||
|
|
|
|||
|
|
@ -67,11 +67,17 @@ object TrackingNotificationHelper {
|
|||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
// SystemUI renders the ticking elapsed time via the chronometer, so the
|
||||
// service never has to re-post the notification just to advance the
|
||||
// timer — a 1 Hz notify() loop kept the process busy around the clock
|
||||
// and showed up as battery drain (#8243).
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_sp)
|
||||
.setContentTitle(taskTitle)
|
||||
.setContentText(formatDuration(elapsedMs))
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setWhen(System.currentTimeMillis() - elapsedMs)
|
||||
.setShowWhen(true)
|
||||
.setUsesChronometer(true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
|
|
@ -81,17 +87,4 @@ object TrackingNotificationHelper {
|
|||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun formatDuration(ms: Long): String {
|
||||
val totalSeconds = ms / 1000
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
val seconds = totalSeconds % 60
|
||||
|
||||
return when {
|
||||
hours > 0 -> String.format("%dh %dm %ds", hours, minutes, seconds)
|
||||
minutes > 0 -> String.format("%dm %ds", minutes, seconds)
|
||||
else -> String.format("%ds", seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ These work everywhere in the app.
|
|||
- `Ctrl`+`0` (zero): Zoom default (Desktop/Firefox/Chrome)
|
||||
- `Ctrl`+`S`: Trigger sync (if configured)
|
||||
|
||||
The search bar searches open tasks by default. Enable **Include completed and archived tasks** in the search view to include older completed results.
|
||||
|
||||
### Add Task Bar (while the add bar input is focused)
|
||||
|
||||
- `Ctrl+1`: Toggle add to top / bottom of list
|
||||
|
|
@ -119,6 +121,8 @@ Shortcuts are stored in global config (`GlobalConfigState.keyboard`) and persist
|
|||
|
||||
**Task-level shortcuts:** Actions with a task focus (e.g. mark done, add subtask, schedule, delete, move) apply to the currently selected task. Selection is by keyboard (tab/arrows) or mouse. The [[3.02-Settings-and-Preferences]] keyboard help text describes these as applying to "the currently selected task."
|
||||
|
||||
Specifically for the **Next Month** shortcut, the task is scheduled for the **1st day of the next month**. This avoids issues with different month lengths (e.g., scheduling for the 31st when the next month only has 30 days).
|
||||
|
||||
**Schedule week view:** `Ctrl`+MouseWheel changes the vertical scale of the week schedule grid. The value is stored locally for future schedule visits.
|
||||
|
||||
**Focus Mode overlay:** `Escape` closes the overlay. If a focus session or break is running, it continues in the header focus button.
|
||||
|
|
@ -128,7 +132,7 @@ Shortcuts are stored in global config (`GlobalConfigState.keyboard`) and persist
|
|||
## Conflict and Precedence
|
||||
|
||||
- **Input focus:** When focus is in an input element or a blocking overlay is open, shortcut handling is skipped so typing is not interrupted.
|
||||
- **Contextual Precedence:** Task-level shortcuts take precedence over global shortcuts when a task is currently focused. For example, pressing `Shift+S` opens the deadline dialog if a task is focused, but navigates to the Schedule view if no task is focused. Similarly, `Shift+T` moves the selected task to Today, but navigates to the Today view otherwise.
|
||||
- **Contextual Precedence:** Task-level shortcuts take precedence over global shortcuts when a task is currently focused. For example, pressing `Shift+S` opens the deadline dialog if a task is focused, but navigates to the Schedule view if no task is focused. Similarly, `Shift+T` schedules the selected task for today, but navigates to the Today view otherwise.
|
||||
- **Conflicts:** The configuration UI warns if two actions are assigned the same key combination. The user can press **Escape**, **Backspace**, or **Delete** in the shortcut field to clear an existing assignment.
|
||||
|
||||
## Storage and Definition
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ Every issue provider supports the same basic operations: checking that the integ
|
|||
|
||||
- **Issue import:** Issues from projects.
|
||||
- **Status transitions / Worklog / Comments / Subtasks / Attachments:** Not supported.
|
||||
- **Filtering:** Project-based; scope support.
|
||||
- **Filtering:** Project-based; scope support; task search supports non-Latin subject matches.
|
||||
- **Auto-import:** Last 100 issues for the project.
|
||||
|
||||
### 9. Trello
|
||||
|
|
|
|||
|
|
@ -29,9 +29,16 @@ import { getIsMinimizeToTray, getIsQuiting, setIsQuiting } from './shared-state'
|
|||
import { loadSimpleStoreAll } from './simple-store';
|
||||
import { SimpleStoreKey } from './shared-with-frontend/simple-store.const';
|
||||
import { markGpuStartupSuccess } from './gpu-startup-guard';
|
||||
import { isAppOriginUrl } from './navigation-guard';
|
||||
|
||||
let mainWin: BrowserWindow;
|
||||
|
||||
// The URL passed to `mainWin.loadURL()` — the single source of truth for
|
||||
// "what is the app's own origin?". Read by the will-navigate / will-redirect
|
||||
// guards in `initWinEventListeners`. Set in `createWindow`, before listeners
|
||||
// are wired, so the guard never sees `undefined` at runtime.
|
||||
let appLoadedUrl: string | undefined;
|
||||
|
||||
// Compact WCO band on Win/Linux. Native button width is OS-controlled
|
||||
// (~138px total); only height is configurable. Lower values may be
|
||||
// clamped to the OS minimum (~24–28px on Win11) — Electron silently
|
||||
|
|
@ -296,6 +303,11 @@ export const createWindow = async ({
|
|||
? 'http://localhost:4200'
|
||||
: `file://${normalize(join(__dirname, '../.tmp/angular-dist/browser/index.html'))}`;
|
||||
|
||||
// Capture the loaded URL so the navigation guard (initWinEventListeners →
|
||||
// will-navigate) can compare against the actual app origin, not a derived
|
||||
// guess. Any URL change here automatically tightens the guard.
|
||||
appLoadedUrl = url;
|
||||
|
||||
mainWin.loadURL(url).then(() => {
|
||||
// Set window title for dev mode
|
||||
if (IS_DEV) {
|
||||
|
|
@ -446,31 +458,53 @@ function initWinEventListeners(app: Electron.App): void {
|
|||
});
|
||||
};
|
||||
|
||||
// open new window links in browser
|
||||
// Compare the navigation target against the URL the app actually loaded
|
||||
// (captured at loadURL time in createWindow). Anything else is treated as
|
||||
// external and routed through the scheme-guarded `openUrlInBrowser`.
|
||||
//
|
||||
// The main window has Node integration via the preload bridge (`window.ea`).
|
||||
// Allowing in-window navigation to ANY other origin — including
|
||||
// http://127.0.0.1:<any-port> — would expose that bridge to whatever page
|
||||
// happens to be served there (a malicious local web server, a sibling
|
||||
// electron app, etc.). The previous host-only check accepted those.
|
||||
//
|
||||
// Hash-only changes do NOT fire will-navigate, so this never fires for
|
||||
// the app's own hash routes (HashLocationStrategy in src/main.ts).
|
||||
const guardNavigation = (
|
||||
ev: { preventDefault: () => void },
|
||||
url: string,
|
||||
eventLabel: string,
|
||||
): void => {
|
||||
if (appLoadedUrl && isAppOriginUrl(url, appLoadedUrl)) return;
|
||||
ev.preventDefault();
|
||||
log(`Blocked in-window navigation (${eventLabel})`);
|
||||
openUrlInBrowser(url);
|
||||
};
|
||||
|
||||
mainWin.webContents.on('will-navigate', (ev, url) => {
|
||||
// Navigate in-window only for the app's own dev-server origin
|
||||
// (http://localhost:4200). Prod serves from file:// (start URL above) and
|
||||
// routes via hash (withHashLocation, src/main.ts), so will-navigate never
|
||||
// fires for the app's own routes there — only for real external links,
|
||||
// which we hand to the (scheme-guarded) browser. Compare the host exactly:
|
||||
// a substring match let hosts like `https://localhost.evil.com` navigate
|
||||
// the main window directly.
|
||||
let isInternalNavigation = false;
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
isInternalNavigation = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
} catch {
|
||||
isInternalNavigation = false;
|
||||
}
|
||||
if (!isInternalNavigation) {
|
||||
ev.preventDefault();
|
||||
openUrlInBrowser(url);
|
||||
}
|
||||
guardNavigation(ev, url, 'will-navigate');
|
||||
});
|
||||
// Defense in depth: a same-origin navigation could redirect to a different
|
||||
// origin server-side. Re-run the same check on the redirect target so a
|
||||
// ‘302 → http://127.0.0.1:1337’ cannot land the bridge on an attacker page.
|
||||
mainWin.webContents.on('will-redirect', (ev, url) => {
|
||||
guardNavigation(ev, url, 'will-redirect');
|
||||
});
|
||||
mainWin.webContents.setWindowOpenHandler((details) => {
|
||||
openUrlInBrowser(details.url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
// Defense in depth: setWindowOpenHandler already denies, so this should
|
||||
// never fire. If a future code path ever enables window creation, destroy
|
||||
// the spawned window rather than letting it inherit the preload bridge.
|
||||
mainWin.webContents.on('did-create-window', (childWin) => {
|
||||
error('did-create-window fired despite deny handler — destroying child');
|
||||
try {
|
||||
childWin.destroy();
|
||||
} catch (e) {
|
||||
error('Failed to destroy unexpected child window:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO refactor quitting mess
|
||||
appCloseHandler(app);
|
||||
|
|
|
|||
143
electron/navigation-guard.test.cjs
Normal file
143
electron/navigation-guard.test.cjs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
// Match the layout used by the other *.test.cjs files: resolve the .ts source
|
||||
// via a computed path so tools/verify-electron-requires.js doesn't flag a
|
||||
// literal relative require of a file excluded from app.asar.
|
||||
const { isAppOriginUrl } = require(path.resolve(__dirname, 'navigation-guard.ts'));
|
||||
|
||||
const DEV_APP_URL = 'http://localhost:4200';
|
||||
const PROD_APP_URL = 'file:///Applications/SP.app/Contents/Resources/index.html';
|
||||
|
||||
test('dev: exact origin matches', () => {
|
||||
assert.equal(isAppOriginUrl('http://localhost:4200/', DEV_APP_URL), true);
|
||||
assert.equal(isAppOriginUrl('http://localhost:4200/#/foo', DEV_APP_URL), true);
|
||||
assert.equal(isAppOriginUrl('http://localhost:4200/some-route', DEV_APP_URL), true);
|
||||
});
|
||||
|
||||
test('dev: different port on localhost is rejected', () => {
|
||||
// The whole point of the fix: 127.0.0.1:1337 or localhost:9999 must NOT
|
||||
// inherit the preload bridge just because the hostname looks local.
|
||||
assert.equal(isAppOriginUrl('http://localhost:1337/', DEV_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('http://localhost:9999/', DEV_APP_URL), false);
|
||||
});
|
||||
|
||||
test('dev: 127.0.0.1 is rejected (different host than localhost)', () => {
|
||||
// The old guard accepted any "localhost OR 127.0.0.1" — anything serving
|
||||
// on a loopback address could load in the privileged window. After the
|
||||
// fix, only the exact origin the app loaded is allowed.
|
||||
assert.equal(isAppOriginUrl('http://127.0.0.1:4200/', DEV_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('http://127.0.0.1:1337/', DEV_APP_URL), false);
|
||||
});
|
||||
|
||||
test('dev: localhost.evil.com substring attack is rejected', () => {
|
||||
// Regression for the earlier substring-match bug (fixed in 4bf699735).
|
||||
assert.equal(isAppOriginUrl('http://localhost.evil.com/', DEV_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('http://evil.localhost:4200/', DEV_APP_URL), false);
|
||||
});
|
||||
|
||||
test('dev: userinfo @ trick is rejected (host is the part after @)', () => {
|
||||
// WHATWG URL parses `http://localhost:4200@evil.com/` with host=evil.com,
|
||||
// username=localhost. A naive substring/startsWith check would be fooled;
|
||||
// the host-equality check correctly rejects it.
|
||||
assert.equal(isAppOriginUrl('http://localhost:4200@evil.com/', DEV_APP_URL), false);
|
||||
assert.equal(
|
||||
isAppOriginUrl('http://localhost@evil.com:4200/', DEV_APP_URL),
|
||||
false,
|
||||
'evil.com:4200 with localhost as username is still not our origin',
|
||||
);
|
||||
});
|
||||
|
||||
test('dev: https variant is rejected (protocol must match)', () => {
|
||||
assert.equal(isAppOriginUrl('https://localhost:4200/', DEV_APP_URL), false);
|
||||
});
|
||||
|
||||
test('dev: external https URL is rejected', () => {
|
||||
assert.equal(isAppOriginUrl('https://example.com/', DEV_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('https://jira.example.com/browse/X-1', DEV_APP_URL), false);
|
||||
});
|
||||
|
||||
test('prod: same file:// path matches', () => {
|
||||
assert.equal(isAppOriginUrl(PROD_APP_URL, PROD_APP_URL), true);
|
||||
});
|
||||
|
||||
test('prod: rejects ALL http(s) localhost URLs (no dev-only opt-in)', () => {
|
||||
// Production never legitimately navigates to http://localhost:*. The fix
|
||||
// is what makes this hold: no host-based allowlist, only "matches the
|
||||
// app's actual loaded URL".
|
||||
assert.equal(isAppOriginUrl('http://localhost:4200/', PROD_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('http://localhost:1337/', PROD_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('http://127.0.0.1:1337/', PROD_APP_URL), false);
|
||||
});
|
||||
|
||||
test('prod: rejects different file:// targets', () => {
|
||||
assert.equal(
|
||||
isAppOriginUrl('file:///etc/passwd', PROD_APP_URL),
|
||||
false,
|
||||
'arbitrary file path',
|
||||
);
|
||||
assert.equal(
|
||||
isAppOriginUrl(
|
||||
'file:///Applications/SP.app/Contents/Resources/other.html',
|
||||
PROD_APP_URL,
|
||||
),
|
||||
false,
|
||||
'sibling html under the app bundle',
|
||||
);
|
||||
});
|
||||
|
||||
test('prod: rejects UNC / remote-host file:// even when pathname matches', () => {
|
||||
// Regression: `file://` URLs carry a host. A pathname-only check matched
|
||||
// `file://192.168.1.100/Applications/SP.app/Contents/Resources/index.html`
|
||||
// against the local `file:///…/index.html` because both pathnames are
|
||||
// `/Applications/SP.app/Contents/Resources/index.html` — letting an
|
||||
// attacker-controlled UNC host load in the privileged window.
|
||||
assert.equal(
|
||||
isAppOriginUrl(
|
||||
'file://192.168.1.100/Applications/SP.app/Contents/Resources/index.html',
|
||||
PROD_APP_URL,
|
||||
),
|
||||
false,
|
||||
'UNC host with matching pathname must be rejected',
|
||||
);
|
||||
assert.equal(
|
||||
isAppOriginUrl(
|
||||
'file://attacker.example.com/Applications/SP.app/Contents/Resources/index.html',
|
||||
PROD_APP_URL,
|
||||
),
|
||||
false,
|
||||
'remote-host file:// with matching pathname must be rejected',
|
||||
);
|
||||
// Even an empty-but-malformed authority pattern should not bypass it.
|
||||
assert.equal(
|
||||
isAppOriginUrl('file://localhost/etc/passwd', PROD_APP_URL),
|
||||
false,
|
||||
'localhost-host file:// pointing elsewhere is rejected',
|
||||
);
|
||||
});
|
||||
|
||||
test('non-http(s)/file schemes are rejected', () => {
|
||||
// data:/blob:/javascript:/etc must never land in the privileged window.
|
||||
assert.equal(isAppOriginUrl('data:text/html,<h1>x</h1>', DEV_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('javascript:alert(1)', DEV_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('blob:http://localhost:4200/abcd', DEV_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('about:blank', DEV_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('ftp://localhost:4200/x', DEV_APP_URL), false);
|
||||
});
|
||||
|
||||
test('malformed URLs are rejected', () => {
|
||||
assert.equal(isAppOriginUrl('not a url', DEV_APP_URL), false);
|
||||
assert.equal(isAppOriginUrl('', DEV_APP_URL), false);
|
||||
});
|
||||
|
||||
test('custom dev URL: exact origin still required', () => {
|
||||
// Some setups override the dev URL (electron CLI). The guard always reads
|
||||
// back the actual loaded URL, so a custom dev origin works without code
|
||||
// changes — but still rejects look-alikes.
|
||||
const customUrl = 'http://localhost:9999';
|
||||
assert.equal(isAppOriginUrl('http://localhost:9999/route', customUrl), true);
|
||||
assert.equal(isAppOriginUrl('http://localhost:4200/', customUrl), false);
|
||||
});
|
||||
46
electron/navigation-guard.ts
Normal file
46
electron/navigation-guard.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Returns true if `targetUrl` is the same origin as the app's loaded URL.
|
||||
*
|
||||
* Security boundary: the main window has Node integration / preload bridge.
|
||||
* A navigation to ANY other origin in-window would expose `window.ea` to
|
||||
* untrusted content (e.g. http://127.0.0.1:<any> hosting a malicious page).
|
||||
* `will-navigate` MUST reject anything this returns false for and hand it
|
||||
* to the (scheme-guarded) external-open path instead.
|
||||
*
|
||||
* Comparison rules:
|
||||
* - Protocols must match exactly.
|
||||
* - http/https: exact host (incl. port) match. Subdomains differ → different
|
||||
* origin. This is what blocks `localhost.evil.com` and `http://127.0.0.1:1`.
|
||||
* - file: BOTH host AND pathname must match the app's loaded html file.
|
||||
* `file://` URLs may carry a host (UNC paths / remote shares), and Chromium
|
||||
* resolves the pathname relative to that host — so a target like
|
||||
* `file://192.168.1.100/Applications/SP.app/Contents/Resources/index.html`
|
||||
* has the same `.pathname` as the local app start URL. A pathname-only
|
||||
* check would let an attacker-controlled UNC host load in the privileged
|
||||
* window. The app's loaded file:// URL is always local (empty host), so we
|
||||
* require `target.host === ''` explicitly rather than relying on the
|
||||
* host-equality compare to coincidentally match.
|
||||
* Hash-only changes do not fire `will-navigate`, so a same-document hash
|
||||
* route never reaches this check.
|
||||
* - Anything else (data:, blob:, javascript:, ftp:, …): rejected.
|
||||
*/
|
||||
export const isAppOriginUrl = (targetUrl: string, appUrl: string): boolean => {
|
||||
let target: URL;
|
||||
let expected: URL;
|
||||
try {
|
||||
target = new URL(targetUrl);
|
||||
expected = new URL(appUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (target.protocol !== expected.protocol) return false;
|
||||
if (target.protocol === 'http:' || target.protocol === 'https:') {
|
||||
return target.host === expected.host;
|
||||
}
|
||||
if (target.protocol === 'file:') {
|
||||
return (
|
||||
target.host === '' && expected.host === '' && target.pathname === expected.pathname
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
|
@ -224,6 +224,21 @@ module.exports = tseslint.config(
|
|||
'local-rules/no-multi-entity-effect': 'warn',
|
||||
},
|
||||
},
|
||||
// App code must route logging through Log/SyncLog/OpLog/... helpers.
|
||||
// Direct console.* calls bypass the exportable log history users attach
|
||||
// to bug reports. The Log implementation itself, tests, and benchmarks
|
||||
// (which intentionally dump timing numbers to stdout) are exempt.
|
||||
{
|
||||
files: ['src/app/**/*.ts'],
|
||||
ignores: [
|
||||
'src/app/**/*.spec.ts',
|
||||
'src/app/**/*.benchmark.ts',
|
||||
'src/app/core/log.ts',
|
||||
],
|
||||
rules: {
|
||||
'no-console': 'error',
|
||||
},
|
||||
},
|
||||
// HTML files
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { Project } from '../../../features/project/project.model';
|
|||
import { isSingleEmoji } from '../../../util/extract-first-emoji';
|
||||
import { expandCollapseAni } from '../../../ui/tree-dnd/tree.animations';
|
||||
import { Router } from '@angular/router';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
const EXPAND_ANIMATION_RESET_DELAY_MS = 250;
|
||||
|
||||
|
|
@ -179,10 +180,7 @@ export class NavListTreeComponent implements OnDestroy {
|
|||
node: TreeNode<MenuTreeViewNode>,
|
||||
): void {
|
||||
// TODO: Implement folder context menu
|
||||
console.log(
|
||||
'Folder context menu for:',
|
||||
node.data?.k === MenuTreeKind.FOLDER ? node.data.name : 'unknown',
|
||||
);
|
||||
Log.log('Folder context menu for:', { id: node.id, kind: node.data?.k });
|
||||
}
|
||||
|
||||
private _toggleFolder(treeNodeId: string): void {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { firstValueFrom, Observable, of } from 'rxjs';
|
|||
import { AsyncPipe } from '@angular/common';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { openWorkContextSettingsDialog } from '../../features/work-context/dialog-work-context-settings/open-work-context-settings-dialog';
|
||||
import { Log } from '../../core/log';
|
||||
|
||||
@Component({
|
||||
selector: 'work-context-menu',
|
||||
|
|
@ -241,7 +242,7 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
try {
|
||||
return await this._projectService.getCompletionInfo(this.contextId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
Log.err(err);
|
||||
this._snackService.open({ type: 'ERROR', msg: T.F.PROJECT.COMPLETE.ERROR });
|
||||
return null;
|
||||
}
|
||||
|
|
@ -301,7 +302,7 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
msg: T.GLOBAL_SNACK.DUPLICATE_PROJECT_ERROR,
|
||||
type: 'ERROR',
|
||||
});
|
||||
console.error(err);
|
||||
Log.err(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -447,7 +448,7 @@ export class WorkContextMenuComponent implements OnInit {
|
|||
msg: T.GLOBAL_SNACK.OPEN_SETTINGS_ERROR,
|
||||
type: 'ERROR',
|
||||
});
|
||||
console.error(err);
|
||||
Log.err(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { T } from '../../t.const';
|
|||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
import { getDefaultClipboardImagesPath } from '../../util/get-default-clipboard-images-path';
|
||||
import { MIME_TYPE_EXTENSIONS } from '../../../../electron/shared-with-frontend/mime-type-mapping.const';
|
||||
import { Log } from '../log';
|
||||
|
||||
const DB_NAME = 'sp-clipboard-images';
|
||||
const DB_VERSION = 1;
|
||||
|
|
@ -117,7 +118,7 @@ export class ClipboardImageService {
|
|||
return { success: true, imageUrl, markdownText };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CLIPBOARD] Error getting file path:', error);
|
||||
Log.err('[CLIPBOARD] Error getting file path:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -164,8 +165,8 @@ export class ClipboardImageService {
|
|||
|
||||
return { success: true, imageUrl, markdownText };
|
||||
} catch (error) {
|
||||
console.error('[CLIPBOARD] Error saving clipboard image:', error);
|
||||
console.error(
|
||||
Log.err('[CLIPBOARD] Error saving clipboard image:', error);
|
||||
Log.err(
|
||||
'[CLIPBOARD] Error stack:',
|
||||
error instanceof Error ? error.stack : 'no stack',
|
||||
);
|
||||
|
|
@ -234,7 +235,7 @@ export class ClipboardImageService {
|
|||
|
||||
return { success: true, imageUrl, markdownText };
|
||||
} catch (error) {
|
||||
console.error('Error getting image from file paths:', error);
|
||||
Log.err('Error getting image from file paths:', error);
|
||||
return null; // Fall back to regular clipboard handling
|
||||
}
|
||||
}
|
||||
|
|
@ -316,7 +317,7 @@ export class ClipboardImageService {
|
|||
this._blobUrlCache.set(imageId, blobUrl);
|
||||
return blobUrl;
|
||||
} catch (error) {
|
||||
console.error('Error resolving indexeddb URL for clipboard image:', error);
|
||||
Log.err('Error resolving indexeddb URL for clipboard image:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import { ClipboardImageService } from './clipboard-image.service';
|
||||
import { Log } from '../log';
|
||||
|
||||
/**
|
||||
* Directive that resolves indexeddb:// URLs in img elements within the host element.
|
||||
|
|
@ -98,7 +99,7 @@ export class ResolveClipboardImagesDirective implements OnInit, OnDestroy, OnCha
|
|||
img.classList.add('indexeddb-image-error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resolving clipboard image:', error);
|
||||
Log.err('Error resolving clipboard image:', error);
|
||||
img.alt = 'Error loading image';
|
||||
img.classList.add('indexeddb-image-error');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Pipe, PipeTransform, inject } from '@angular/core';
|
|||
import { ClipboardImageService } from './clipboard-image.service';
|
||||
import { Observable, from, of, defer } from 'rxjs';
|
||||
import { map, shareReplay, catchError } from 'rxjs/operators';
|
||||
import { Log } from '../log';
|
||||
|
||||
@Pipe({
|
||||
name: 'resolveClipboardUrl',
|
||||
|
|
@ -38,7 +39,7 @@ export class ResolveClipboardUrlPipe implements PipeTransform {
|
|||
return url;
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Error resolving clipboard URL:', error);
|
||||
Log.err('Error resolving clipboard URL:', error);
|
||||
return of(url);
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export const logAdvancedStacktrace = (
|
|||
|
||||
// NOTE: there is an issue with this sometimes -> https://github.com/stacktracejs/stacktrace.js/issues/202
|
||||
})
|
||||
.catch(console.error);
|
||||
.catch((err) => Log.err(err));
|
||||
|
||||
const _cleanHtml = (str: string): string => {
|
||||
const div = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Directory, Filesystem } from '@capacitor/filesystem';
|
||||
import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform';
|
||||
import { ShareResult } from './share.model';
|
||||
import { Log } from '../log';
|
||||
|
||||
/**
|
||||
* Sanitize filename for safe file system usage.
|
||||
|
|
@ -43,7 +44,7 @@ export const downloadBlob = (blob: Blob, filename: string): boolean => {
|
|||
anchor.click();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Browser download failed:', error);
|
||||
Log.warn('Browser download failed:', error);
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(anchor);
|
||||
|
|
@ -65,7 +66,7 @@ export const cleanupCacheFile = async (relativePath: string): Promise<void> => {
|
|||
directory: Directory.Cache,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to cleanup shared file:', cleanupError);
|
||||
Log.warn('Failed to cleanup shared file:', cleanupError);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -120,7 +121,7 @@ export const openDownloadResult = async (result: ShareResult): Promise<void> =>
|
|||
(window as any).ea.openPath(path);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Failed to open path via Electron bridge:', error);
|
||||
Log.warn('Failed to open path via Electron bridge:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +132,7 @@ export const openDownloadResult = async (result: ShareResult): Promise<void> =>
|
|||
window.open(candidate, '_blank', 'noopener');
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('Failed to open download in new window:', error);
|
||||
Log.warn('Failed to open download in new window:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import * as ShareTextUtil from './share-text.util';
|
|||
import * as ShareUrlBuilder from './share-url-builder.util';
|
||||
import * as ShareFileUtil from './share-file.util';
|
||||
import * as SharePlatformUtil from './share-platform.util';
|
||||
import { Log } from '../log';
|
||||
|
||||
export type ShareOutcome = 'shared' | 'cancelled' | 'unavailable' | 'failed';
|
||||
export type { ShareSupport } from './share-platform.util';
|
||||
|
|
@ -252,7 +253,7 @@ export class ShareService {
|
|||
error: 'Share cancelled',
|
||||
};
|
||||
}
|
||||
console.warn('Capacitor share failed:', error);
|
||||
Log.warn('Capacitor share failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -273,7 +274,7 @@ export class ShareService {
|
|||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return { success: false, error: 'Share cancelled' };
|
||||
}
|
||||
console.warn('Web Share API failed:', error);
|
||||
Log.warn('Web Share API failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -524,7 +525,7 @@ export class ShareService {
|
|||
? `file://${resolvedUri}`
|
||||
: `file:///${resolvedUri}`;
|
||||
|
||||
console.debug('[ShareService] shareCanvasViaNative', {
|
||||
Log.debug('[ShareService] shareCanvasViaNative', {
|
||||
resolvedUri,
|
||||
fileUrl,
|
||||
relativePath,
|
||||
|
|
@ -535,9 +536,9 @@ export class ShareService {
|
|||
path: relativePath,
|
||||
directory: Directory.Cache,
|
||||
});
|
||||
console.debug('[ShareService] shareCanvasViaNative stat', stat);
|
||||
Log.debug('[ShareService] shareCanvasViaNative stat', stat);
|
||||
} catch (statError) {
|
||||
console.warn('[ShareService] stat failed for shared image', statError);
|
||||
Log.warn('[ShareService] stat failed for shared image', statError);
|
||||
}
|
||||
|
||||
const canShare = (await sharePlugin.canShare?.())?.value ?? true;
|
||||
|
|
@ -570,7 +571,7 @@ export class ShareService {
|
|||
error: 'Share cancelled',
|
||||
};
|
||||
}
|
||||
console.warn('Native image share failed:', error, {
|
||||
Log.warn('Native image share failed:', error, {
|
||||
fileUrl: fileUrl ?? 'n/a',
|
||||
resolvedUri: resolvedUri ?? 'n/a',
|
||||
relativePath,
|
||||
|
|
@ -633,7 +634,7 @@ export class ShareService {
|
|||
error: 'Share cancelled',
|
||||
};
|
||||
}
|
||||
console.warn('Web Share API failed:', error);
|
||||
Log.warn('Web Share API failed:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -683,7 +684,7 @@ export class ShareService {
|
|||
uri,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Native download failed, falling back to browser download:', error);
|
||||
Log.warn('Native download failed, falling back to browser download:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -703,7 +704,7 @@ export class ShareService {
|
|||
target: 'download',
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Opening image in new tab failed:', error);
|
||||
Log.warn('Opening image in new tab failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
|
|||
import { Observable, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { getEnvOptional } from '../../util/env';
|
||||
import { Log } from '../log';
|
||||
|
||||
export interface UnsplashPhoto {
|
||||
id: string;
|
||||
|
|
@ -56,7 +57,7 @@ export class UnsplashService {
|
|||
}
|
||||
|
||||
if (!this.ACCESS_KEY) {
|
||||
console.warn(
|
||||
Log.warn(
|
||||
'No Unsplash Access Key configured. Register at https://unsplash.com/developers?utm_source=super-productivity&utm_medium=referral&utm_campaign=api-credit',
|
||||
);
|
||||
return of({ results: [], total: 0, total_pages: 0 });
|
||||
|
|
@ -81,7 +82,7 @@ export class UnsplashService {
|
|||
})
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Unsplash API error:', error);
|
||||
Log.err('Unsplash API error:', error);
|
||||
return of({ results: [], total: 0, total_pages: 0 });
|
||||
}),
|
||||
);
|
||||
|
|
@ -122,12 +123,12 @@ export class UnsplashService {
|
|||
*/
|
||||
trackPhotoDownload(photo: UnsplashPhoto): Observable<any> {
|
||||
if (!photo.links?.download_location) {
|
||||
console.warn('No download_location available for photo', photo.id);
|
||||
Log.warn('No download_location available for photo', photo.id);
|
||||
return of(null);
|
||||
}
|
||||
|
||||
if (!this.ACCESS_KEY) {
|
||||
console.warn('No Unsplash Access Key configured');
|
||||
Log.warn('No Unsplash Access Key configured');
|
||||
return of(null);
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +139,7 @@ export class UnsplashService {
|
|||
// Call the download endpoint to track usage
|
||||
return this._http.get(photo.links.download_location, { headers }).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to track photo download:', error);
|
||||
Log.err('Failed to track photo download:', error);
|
||||
// Don't fail the selection if tracking fails
|
||||
return of(null);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -36,10 +36,16 @@ export const createFocusResumeTick$ = (onResume$: Observable<void>): Observable<
|
|||
|
||||
/**
|
||||
* Whether the focus-mode notification needs a fresh push to the native service.
|
||||
* Elapsed-only changes are throttled to 5s (the native handler already ticks
|
||||
* every second), but pause/purpose changes — and the large elapsed jump a resume
|
||||
* `tick()` produces (#7856) — must propagate immediately so the notification
|
||||
* reconciles with the corrected in-app countdown.
|
||||
* The notification's chronometer ticks natively, so steady-state elapsed
|
||||
* changes need no push at all — and since prev/curr are consecutive per-tick
|
||||
* emissions (~1s apart), the 5s gate suppresses them entirely (#8243). Do not
|
||||
* weaken it: every push re-runs startForeground + a notification rebuild.
|
||||
* Pause/purpose changes — and the large elapsed jump a resume `tick()`
|
||||
* produces (#7856) — must propagate immediately so the notification
|
||||
* reconciles with the corrected in-app countdown. (The 5000 here and
|
||||
* TIME_SPENT_JUMP_THRESHOLD_MS in android-foreground-tracking.effects.ts
|
||||
* encode the same "larger than any tick" idea but differ in semantics —
|
||||
* abs() vs decrease-always-passes — so they are deliberately not shared.)
|
||||
*/
|
||||
export const hasFocusNotificationStateChanged = (
|
||||
prevTimer: TimerState | undefined,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { BehaviorSubject } from 'rxjs';
|
|||
import { TaskService } from '../../tasks/task.service';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { Task } from '../../tasks/task.model';
|
||||
import { parseNativeTrackingData } from './android-foreground-tracking.effects';
|
||||
import {
|
||||
isTimeSpentJumpForNotification,
|
||||
parseNativeTrackingData,
|
||||
TIME_SPENT_JUMP_THRESHOLD_MS,
|
||||
} from './android-foreground-tracking.effects';
|
||||
|
||||
// We need to test the effect logic by reimplementing it in tests since
|
||||
// the actual effects are conditionally created based on IS_ANDROID_WEB_VIEW
|
||||
|
|
@ -47,7 +51,7 @@ describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () =>
|
|||
prevState.taskId === currState.taskId &&
|
||||
currState.taskId !== null &&
|
||||
!currState.isFocusModeActive &&
|
||||
prevState.timeSpent !== currState.timeSpent;
|
||||
isTimeSpentJumpForNotification(prevState.timeSpent, currState.timeSpent);
|
||||
|
||||
expect(shouldUpdate).toBeTrue();
|
||||
|
||||
|
|
@ -67,7 +71,7 @@ describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () =>
|
|||
prevState.taskId === currState.taskId &&
|
||||
currState.taskId !== null &&
|
||||
!currState.isFocusModeActive &&
|
||||
prevState.timeSpent !== currState.timeSpent;
|
||||
isTimeSpentJumpForNotification(prevState.timeSpent, currState.timeSpent);
|
||||
|
||||
expect(shouldUpdate).toBeFalse();
|
||||
}));
|
||||
|
|
@ -80,7 +84,7 @@ describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () =>
|
|||
prevState.taskId === currState.taskId &&
|
||||
currState.taskId !== null &&
|
||||
!currState.isFocusModeActive &&
|
||||
prevState.timeSpent !== currState.timeSpent;
|
||||
isTimeSpentJumpForNotification(prevState.timeSpent, currState.timeSpent);
|
||||
|
||||
expect(shouldUpdate).toBeFalse();
|
||||
}));
|
||||
|
|
@ -93,7 +97,7 @@ describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () =>
|
|||
prevState.taskId === currState.taskId &&
|
||||
currState.taskId !== null &&
|
||||
!currState.isFocusModeActive &&
|
||||
prevState.timeSpent !== currState.timeSpent;
|
||||
isTimeSpentJumpForNotification(prevState.timeSpent, currState.timeSpent);
|
||||
|
||||
expect(shouldUpdate).toBeFalse();
|
||||
}));
|
||||
|
|
@ -106,7 +110,7 @@ describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () =>
|
|||
prevState.taskId === currState.taskId &&
|
||||
currState.taskId !== null &&
|
||||
!currState.isFocusModeActive &&
|
||||
prevState.timeSpent !== currState.timeSpent;
|
||||
isTimeSpentJumpForNotification(prevState.timeSpent, currState.timeSpent);
|
||||
|
||||
expect(shouldUpdate).toBeFalse();
|
||||
}));
|
||||
|
|
@ -119,7 +123,7 @@ describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () =>
|
|||
prevState.taskId === currState.taskId &&
|
||||
currState.taskId !== null &&
|
||||
!currState.isFocusModeActive &&
|
||||
prevState.timeSpent !== currState.timeSpent;
|
||||
isTimeSpentJumpForNotification(prevState.timeSpent, currState.timeSpent);
|
||||
|
||||
expect(shouldUpdate).toBeTrue();
|
||||
|
||||
|
|
@ -131,6 +135,26 @@ describe('AndroidForegroundTrackingEffects - syncTimeSpentChanges logic', () =>
|
|||
}));
|
||||
});
|
||||
|
||||
describe('isTimeSpentJumpForNotification', () => {
|
||||
it('should suppress increments at or below the threshold', () => {
|
||||
expect(isTimeSpentJumpForNotification(0, TIME_SPENT_JUMP_THRESHOLD_MS)).toBeFalse();
|
||||
expect(isTimeSpentJumpForNotification(60000, 61000)).toBeFalse();
|
||||
expect(isTimeSpentJumpForNotification(60000, 60000)).toBeFalse();
|
||||
});
|
||||
|
||||
it('should pass increments above the threshold (manual edit, sync merge)', () => {
|
||||
expect(
|
||||
isTimeSpentJumpForNotification(0, TIME_SPENT_JUMP_THRESHOLD_MS + 1),
|
||||
).toBeTrue();
|
||||
expect(isTimeSpentJumpForNotification(60000, 600000)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should pass any decrease (manual correction, idle time removed)', () => {
|
||||
expect(isTimeSpentJumpForNotification(60000, 59999)).toBeTrue();
|
||||
expect(isTimeSpentJumpForNotification(60000, 0)).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('distinctUntilChanged behavior', () => {
|
||||
it('should detect changes when only timeSpent differs', () => {
|
||||
const stateA = { taskId: 'task-1', timeSpent: 60000, isFocusModeActive: false };
|
||||
|
|
|
|||
|
|
@ -89,6 +89,30 @@ export const parseNativeTrackingData = (
|
|||
return { taskId, elapsedMs };
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a timeSpent change must be pushed to the native tracking notification.
|
||||
* While tracking, timeSpent grows by ~one tick (1s) at a time and the
|
||||
* notification's chronometer advances on its own — pushing every tick would
|
||||
* re-post the notification once per second (battery drain, #8243). Only jumps
|
||||
* (manual edits, remote sync merges, idle/recovery corrections) need to
|
||||
* propagate; those show up as a decrease or a step larger than any tick.
|
||||
* Accepted gap: a manual edit that ADDS <= 5s is indistinguishable from a tick
|
||||
* and stays suppressed — the notification (display-only; persistence happens
|
||||
* elsewhere) re-anchors on the next jump or task switch. The threshold is 5x
|
||||
* TRACKING_INTERVAL as headroom for janky/coalesced ticks; a sibling 5000 in
|
||||
* android-focus-mode.effects.ts has different semantics (abs), don't unify.
|
||||
*
|
||||
* Exported so unit tests can exercise it without instantiating the effect
|
||||
* (which is gated behind IS_ANDROID_WEB_VIEW).
|
||||
*/
|
||||
export const TIME_SPENT_JUMP_THRESHOLD_MS = 5000;
|
||||
export const isTimeSpentJumpForNotification = (
|
||||
prevTimeSpent: number,
|
||||
currTimeSpent: number,
|
||||
): boolean =>
|
||||
currTimeSpent < prevTimeSpent ||
|
||||
currTimeSpent - prevTimeSpent > TIME_SPENT_JUMP_THRESHOLD_MS;
|
||||
|
||||
@Injectable()
|
||||
export class AndroidForegroundTrackingEffects {
|
||||
private _store = inject(Store);
|
||||
|
|
@ -335,12 +359,13 @@ export class AndroidForegroundTrackingEffects {
|
|||
// 1. Same task (not switching tasks - that's handled by syncTrackingToService$)
|
||||
// 2. Task exists
|
||||
// 3. Focus mode is not active (notification is hidden during focus mode)
|
||||
// 4. timeSpent actually changed
|
||||
// 4. timeSpent jumped (per-tick increments are rendered by the
|
||||
// notification chronometer natively — no push needed, #8243)
|
||||
return (
|
||||
prev.taskId === curr.taskId &&
|
||||
curr.taskId !== null &&
|
||||
!curr.isFocusModeActive &&
|
||||
prev.timeSpent !== curr.timeSpent
|
||||
isTimeSpentJumpForNotification(prev.timeSpent, curr.timeSpent)
|
||||
);
|
||||
}),
|
||||
tap(([, curr]) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { CalendarEventActionsService } from './calendar-event-actions.service';
|
||||
import { IssueService } from '../issue/issue.service';
|
||||
import { PluginIssueProviderRegistryService } from '../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
import { IssueSyncAdapterResolverService } from '../issue/two-way-sync/issue-sync-adapter-resolver.service';
|
||||
import { CalendarIntegrationService } from './calendar-integration.service';
|
||||
import { HiddenCalendarEventsService } from './hidden-calendar-events.service';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
import { IssueSyncAdapter } from '../issue/two-way-sync/issue-sync-adapter.interface';
|
||||
import { ScheduleFromCalendarEvent } from '../schedule/schedule.model';
|
||||
import { IssueProviderPluginType } from '../issue/issue.model';
|
||||
import { Log } from '../../core/log';
|
||||
import { T } from '../../t.const';
|
||||
|
||||
describe('CalendarEventActionsService', () => {
|
||||
let service: CalendarEventActionsService;
|
||||
let store: jasmine.SpyObj<Store>;
|
||||
let matDialog: jasmine.SpyObj<MatDialog>;
|
||||
let pluginRegistry: jasmine.SpyObj<PluginIssueProviderRegistryService>;
|
||||
let adapterResolver: jasmine.SpyObj<IssueSyncAdapterResolverService>;
|
||||
let calendarIntegrationService: jasmine.SpyObj<CalendarIntegrationService>;
|
||||
let snackService: jasmine.SpyObj<SnackService>;
|
||||
let adapter: jasmine.SpyObj<IssueSyncAdapter<unknown>>;
|
||||
|
||||
const providerCfg: IssueProviderPluginType = {
|
||||
id: 'provider-1',
|
||||
isEnabled: true,
|
||||
issueProviderKey: 'plugin:google-calendar-provider',
|
||||
pluginId: 'google-calendar-provider',
|
||||
pluginConfig: { readCalendarIds: ['primary'] },
|
||||
};
|
||||
|
||||
const createCalendarEvent = (
|
||||
overrides: Partial<ScheduleFromCalendarEvent> = {},
|
||||
): ScheduleFromCalendarEvent => ({
|
||||
id: 'cal-1:event-1',
|
||||
calProviderId: 'provider-1',
|
||||
issueProviderKey: 'plugin:google-calendar-provider',
|
||||
title: 'Meeting',
|
||||
start: new Date('2026-03-20T10:00:00').getTime(),
|
||||
duration: 30 * 60 * 1000,
|
||||
isAllDay: false,
|
||||
icon: 'event',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
store = jasmine.createSpyObj<Store>('Store', ['select']);
|
||||
store.select.and.returnValue(of(providerCfg));
|
||||
matDialog = jasmine.createSpyObj<MatDialog>('MatDialog', ['open']);
|
||||
pluginRegistry = jasmine.createSpyObj<PluginIssueProviderRegistryService>(
|
||||
'PluginIssueProviderRegistryService',
|
||||
['getProvider'],
|
||||
);
|
||||
pluginRegistry.getProvider.and.returnValue({
|
||||
definition: { updateIssue: jasmine.createSpy('updateIssue') },
|
||||
} as any);
|
||||
adapter = jasmine.createSpyObj<IssueSyncAdapter<unknown>>('IssueSyncAdapter', [
|
||||
'getFieldMappings',
|
||||
'getSyncConfig',
|
||||
'fetchIssue',
|
||||
'pushChanges',
|
||||
'extractSyncValues',
|
||||
]);
|
||||
adapter.pushChanges.and.resolveTo();
|
||||
adapterResolver = jasmine.createSpyObj<IssueSyncAdapterResolverService>(
|
||||
'IssueSyncAdapterResolverService',
|
||||
['getAdapter'],
|
||||
);
|
||||
adapterResolver.getAdapter.and.returnValue(adapter);
|
||||
calendarIntegrationService = jasmine.createSpyObj<CalendarIntegrationService>(
|
||||
'CalendarIntegrationService',
|
||||
['triggerRefresh'],
|
||||
);
|
||||
snackService = jasmine.createSpyObj<SnackService>('SnackService', ['open']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
CalendarEventActionsService,
|
||||
{ provide: Store, useValue: store },
|
||||
{ provide: IssueService, useValue: {} },
|
||||
{ provide: MatDialog, useValue: matDialog },
|
||||
{ provide: PluginIssueProviderRegistryService, useValue: pluginRegistry },
|
||||
{ provide: IssueSyncAdapterResolverService, useValue: adapterResolver },
|
||||
{ provide: CalendarIntegrationService, useValue: calendarIntegrationService },
|
||||
{ provide: HiddenCalendarEventsService, useValue: {} },
|
||||
{ provide: SnackService, useValue: snackService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(CalendarEventActionsService);
|
||||
});
|
||||
|
||||
it('returns false without loading provider config when no adapter exists', async () => {
|
||||
adapterResolver.getAdapter.and.returnValue(undefined);
|
||||
|
||||
const wasMoved = await service.moveToStartTime(
|
||||
createCalendarEvent(),
|
||||
new Date('2026-03-22T14:30:00').getTime(),
|
||||
);
|
||||
|
||||
expect(wasMoved).toBeFalse();
|
||||
expect(store.select).not.toHaveBeenCalled();
|
||||
expect(snackService.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs sanitized errors and reports failed writes', async () => {
|
||||
const error = {
|
||||
name: 'HttpErrorResponse',
|
||||
message:
|
||||
'Http failure response for https://www.googleapis.com/calendar/v3/calendars/noam@example.com/events/1: 403 Forbidden',
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
url: 'https://www.googleapis.com/calendar/v3/calendars/noam@example.com/events/1',
|
||||
};
|
||||
adapter.pushChanges.and.rejectWith(error);
|
||||
const logSpy = spyOn(Log, 'err');
|
||||
|
||||
const wasMoved = await service.moveToStartTime(
|
||||
createCalendarEvent(),
|
||||
new Date('2026-03-22T14:30:00').getTime(),
|
||||
);
|
||||
|
||||
expect(wasMoved).toBeFalse();
|
||||
expect(logSpy).toHaveBeenCalledWith('Failed to move calendar event', {
|
||||
name: 'HttpErrorResponse',
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
});
|
||||
expect(JSON.stringify(logSpy.calls.mostRecent().args)).not.toContain(
|
||||
'noam@example.com',
|
||||
);
|
||||
expect(snackService.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
type: 'ERROR',
|
||||
translateParams: { errTxt: '403 Forbidden' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers undo that restores the original timed event values', async () => {
|
||||
await service.moveToStartTime(
|
||||
createCalendarEvent(),
|
||||
new Date('2026-03-22T14:30:00').getTime(),
|
||||
);
|
||||
|
||||
const successSnack = snackService.open.calls.mostRecent().args[0] as {
|
||||
actionStr?: string;
|
||||
actionFn?: () => Promise<boolean>;
|
||||
};
|
||||
expect(successSnack.actionStr).toBe(T.G.UNDO);
|
||||
|
||||
await successSnack.actionFn!();
|
||||
|
||||
expect(adapter.pushChanges.calls.mostRecent().args).toEqual([
|
||||
'cal-1:event-1',
|
||||
{
|
||||
['start_dateTime']: new Date('2026-03-20T10:00:00').toISOString(),
|
||||
duration_ms: 30 * 60 * 1000,
|
||||
},
|
||||
providerCfg,
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the same write path for dialog reschedule', async () => {
|
||||
matDialog.open.and.returnValue({
|
||||
afterClosed: () => of({ date: '2026-03-23', time: '09:15' }),
|
||||
} as any);
|
||||
|
||||
await service.reschedule(createCalendarEvent());
|
||||
|
||||
expect(adapter.pushChanges).toHaveBeenCalledWith(
|
||||
'cal-1:event-1',
|
||||
jasmine.objectContaining({
|
||||
duration_ms: 30 * 60 * 1000,
|
||||
}),
|
||||
providerCfg,
|
||||
);
|
||||
expect(calendarIntegrationService.triggerRefresh).toHaveBeenCalled();
|
||||
expect(snackService.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ actionStr: T.G.UNDO }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reschedules all-day events with a date-only change from dialog Date values', async () => {
|
||||
matDialog.open.and.returnValue({
|
||||
afterClosed: () => of({ date: new Date(2026, 2, 23), time: null }),
|
||||
} as any);
|
||||
|
||||
await service.reschedule(
|
||||
createCalendarEvent({
|
||||
isAllDay: true,
|
||||
start: new Date(2026, 2, 20).getTime(),
|
||||
duration: 24 * 60 * 60 * 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(adapter.pushChanges).toHaveBeenCalledWith(
|
||||
'cal-1:event-1',
|
||||
{ start_date: '2026-03-23' },
|
||||
providerCfg,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not open the reschedule dialog when the provider cannot update events', async () => {
|
||||
pluginRegistry.getProvider.and.returnValue({ definition: {} } as any);
|
||||
|
||||
await service.reschedule(createCalendarEvent());
|
||||
|
||||
expect(matDialog.open).not.toHaveBeenCalled();
|
||||
expect(adapter.pushChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
isPluginIssueProvider,
|
||||
} from '../issue/issue.model';
|
||||
import { selectIssueProviderById } from '../issue/store/issue-provider.selectors';
|
||||
import { IssueSyncAdapterRegistryService } from '../issue/two-way-sync/issue-sync-adapter-registry.service';
|
||||
import { IssueSyncAdapterResolverService } from '../issue/two-way-sync/issue-sync-adapter-resolver.service';
|
||||
import { PluginIssueProviderRegistryService } from '../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
import { CalendarIntegrationService } from './calendar-integration.service';
|
||||
import { HiddenCalendarEventsService } from './hidden-calendar-events.service';
|
||||
|
|
@ -21,7 +21,13 @@ import { IS_ELECTRON } from '../../app.constants';
|
|||
import { DialogScheduleTaskComponent } from '../planner/dialog-schedule-task/dialog-schedule-task.component';
|
||||
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
|
||||
import { getDateTimeFromClockString } from '../../util/get-date-time-from-clock-string';
|
||||
import { getErrorTxt } from '../../util/get-error-text';
|
||||
import { getDbDateStr } from '../../util/get-db-date-str';
|
||||
|
||||
interface CalendarEventPushOptions {
|
||||
logMessage: string;
|
||||
undoChanges?: Record<string, unknown>;
|
||||
successMsg?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -31,7 +37,7 @@ export class CalendarEventActionsService {
|
|||
private _issueService = inject(IssueService);
|
||||
private _matDialog = inject(MatDialog);
|
||||
private _pluginRegistry = inject(PluginIssueProviderRegistryService);
|
||||
private _syncAdapterRegistry = inject(IssueSyncAdapterRegistryService);
|
||||
private _syncAdapterResolver = inject(IssueSyncAdapterResolverService);
|
||||
private _calendarIntegrationService = inject(CalendarIntegrationService);
|
||||
private _hiddenEventsService = inject(HiddenCalendarEventsService);
|
||||
private _snackService = inject(SnackService);
|
||||
|
|
@ -40,6 +46,14 @@ export class CalendarEventActionsService {
|
|||
return isPluginIssueProvider(calEv.issueProviderKey as IssueProviderKey);
|
||||
}
|
||||
|
||||
canMoveEvent(calEv: ScheduleFromCalendarEvent): boolean {
|
||||
if (calEv.isReferenceCalendar || !this.isPluginEvent(calEv)) {
|
||||
return false;
|
||||
}
|
||||
const provider = this._pluginRegistry.getProvider(calEv.issueProviderKey);
|
||||
return !!provider?.definition.updateIssue;
|
||||
}
|
||||
|
||||
hasEventUrl(calEv: ScheduleFromCalendarEvent): boolean {
|
||||
return this.isPluginEvent(calEv) || !!calEv.url;
|
||||
}
|
||||
|
|
@ -98,7 +112,7 @@ export class CalendarEventActionsService {
|
|||
if (!isConfirm) {
|
||||
return;
|
||||
}
|
||||
const adapter = this._syncAdapterRegistry.get(
|
||||
const adapter = this._syncAdapterResolver.getAdapter(
|
||||
calEv.issueProviderKey as IssueProviderKey,
|
||||
);
|
||||
if (!adapter?.deleteIssue) {
|
||||
|
|
@ -113,25 +127,24 @@ export class CalendarEventActionsService {
|
|||
msg: T.F.CALENDARS.S.EVENT_DELETED,
|
||||
});
|
||||
} catch (e) {
|
||||
Log.err('Failed to delete calendar event', e);
|
||||
Log.err('Failed to delete calendar event', this._sanitizeLogError(e));
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.CALENDARS.S.CAL_PROVIDER_ERROR,
|
||||
translateParams: { errTxt: getErrorTxt(e) },
|
||||
translateParams: { errTxt: this._getSafeErrorTxt(e) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async reschedule(calEv: ScheduleFromCalendarEvent): Promise<void> {
|
||||
if (!this.isPluginEvent(calEv)) {
|
||||
if (!this.canMoveEvent(calEv)) {
|
||||
return;
|
||||
}
|
||||
const eventDate = new Date(calEv.start);
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
const targetDay = `${eventDate.getFullYear()}-${pad(eventDate.getMonth() + 1)}-${pad(eventDate.getDate())}`;
|
||||
const targetDay = getDbDateStr(eventDate);
|
||||
const targetTime = calEv.isAllDay
|
||||
? undefined
|
||||
: `${pad(eventDate.getHours())}:${pad(eventDate.getMinutes())}`;
|
||||
: `${String(eventDate.getHours()).padStart(2, '0')}:${String(eventDate.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
const dialogRef = this._matDialog.open(DialogScheduleTaskComponent, {
|
||||
restoreFocus: true,
|
||||
|
|
@ -158,38 +171,118 @@ export class CalendarEventActionsService {
|
|||
duration_ms: durationMs,
|
||||
};
|
||||
} else {
|
||||
// Parse as local midnight to avoid UTC date shift in western timezones
|
||||
const d = new Date(result.date + 'T00:00:00');
|
||||
changes = {
|
||||
start_date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
|
||||
start_date: getDbDateStr(result.date),
|
||||
};
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
const adapter = this._syncAdapterRegistry.get(
|
||||
await this._pushChanges(calEv, changes, {
|
||||
logMessage: 'Failed to reschedule calendar event',
|
||||
undoChanges: this._getCurrentScheduleChanges(calEv),
|
||||
});
|
||||
}
|
||||
|
||||
async moveToStartTime(
|
||||
calEv: ScheduleFromCalendarEvent,
|
||||
newStartMs: number,
|
||||
): Promise<boolean> {
|
||||
if (!this.canMoveEvent(calEv)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
const changes = {
|
||||
start_dateTime: new Date(newStartMs).toISOString(),
|
||||
duration_ms: calEv.duration,
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
return this._pushChanges(calEv, changes, {
|
||||
logMessage: 'Failed to move calendar event',
|
||||
undoChanges: this._getCurrentScheduleChanges(calEv),
|
||||
successMsg: T.F.CALENDARS.S.EVENT_MOVED,
|
||||
});
|
||||
}
|
||||
|
||||
private async _pushChanges(
|
||||
calEv: ScheduleFromCalendarEvent,
|
||||
changes: Record<string, unknown>,
|
||||
options: CalendarEventPushOptions,
|
||||
): Promise<boolean> {
|
||||
const adapter = this._syncAdapterResolver.getAdapter(
|
||||
calEv.issueProviderKey as IssueProviderKey,
|
||||
);
|
||||
if (!adapter) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const cfg = await this._getPluginConfig(calEv);
|
||||
await adapter.pushChanges(calEv.id, changes, cfg);
|
||||
this._calendarIntegrationService.triggerRefresh();
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: T.F.CALENDARS.S.EVENT_RESCHEDULED,
|
||||
msg: options.successMsg ?? T.F.CALENDARS.S.EVENT_RESCHEDULED,
|
||||
...(options.undoChanges
|
||||
? {
|
||||
actionStr: T.G.UNDO,
|
||||
actionFn: () =>
|
||||
this._pushChanges(calEv, options.undoChanges!, {
|
||||
logMessage: 'Failed to undo calendar event reschedule',
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
Log.err('Failed to reschedule calendar event', e);
|
||||
Log.err(options.logMessage, this._sanitizeLogError(e));
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.CALENDARS.S.CAL_PROVIDER_ERROR,
|
||||
translateParams: { errTxt: getErrorTxt(e) },
|
||||
translateParams: { errTxt: this._getSafeErrorTxt(e) },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _getCurrentScheduleChanges(
|
||||
calEv: ScheduleFromCalendarEvent,
|
||||
): Record<string, unknown> {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
return calEv.isAllDay
|
||||
? { start_date: getDbDateStr(calEv.start) }
|
||||
: {
|
||||
start_dateTime: new Date(calEv.start).toISOString(),
|
||||
duration_ms: calEv.duration,
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
private _sanitizeLogError(e: unknown): Record<string, unknown> {
|
||||
const err = e as {
|
||||
name?: unknown;
|
||||
status?: unknown;
|
||||
statusText?: unknown;
|
||||
};
|
||||
return {
|
||||
...(typeof err?.name === 'string' ? { name: err.name } : {}),
|
||||
...(typeof err?.status === 'number' ? { status: err.status } : {}),
|
||||
...(typeof err?.statusText === 'string' ? { statusText: err.statusText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private _getSafeErrorTxt(e: unknown): string {
|
||||
const err = this._sanitizeLogError(e);
|
||||
if (typeof err.status === 'number' && typeof err.statusText === 'string') {
|
||||
return `${err.status} ${err.statusText}`;
|
||||
}
|
||||
if (typeof err.status === 'number') {
|
||||
return String(err.status);
|
||||
}
|
||||
return typeof err.name === 'string' ? err.name : T.F.CALENDARS.S.CAL_PROVIDER_ERROR;
|
||||
}
|
||||
|
||||
private async _getPluginConfig(
|
||||
calEv: ScheduleFromCalendarEvent,
|
||||
): Promise<IssueProviderPluginType> {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { MatInput } from '@angular/material/input';
|
|||
import { FormsModule } from '@angular/forms';
|
||||
import { DialogClipboardImagesManagerComponent } from './dialog-clipboard-images-manager/dialog-clipboard-images-manager.component';
|
||||
import { getDefaultClipboardImagesPath } from '../../../util/get-default-clipboard-images-path';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
@Component({
|
||||
selector: 'clipboard-images-cfg',
|
||||
|
|
@ -80,7 +81,7 @@ export class ClipboardImagesCfgComponent implements OnInit {
|
|||
this.defaultImagePath = await getDefaultClipboardImagesPath();
|
||||
this._cd.markForCheck();
|
||||
} catch (error) {
|
||||
console.error('Error loading default clipboard image path:', error);
|
||||
Log.err('Error loading default clipboard image path:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { MatTooltip } from '@angular/material/tooltip';
|
|||
import { MatFormField, MatLabel } from '@angular/material/form-field';
|
||||
import { MatSelect } from '@angular/material/select';
|
||||
import { MatOption } from '@angular/material/core';
|
||||
import { Log } from '../../../../core/log';
|
||||
|
||||
type SortOrder = 'newest' | 'oldest' | 'largest' | 'smallest';
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ export class DialogClipboardImagesManagerComponent implements OnInit {
|
|||
}
|
||||
this.imageUrls.set(urlMap);
|
||||
} catch (error) {
|
||||
console.error('Error loading clipboard images:', error);
|
||||
Log.err('Error loading clipboard images:', error);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: this._translateService.instant(T.GCF.CLIPBOARD_IMAGES.ERROR_LOADING),
|
||||
|
|
@ -124,7 +125,7 @@ export class DialogClipboardImagesManagerComponent implements OnInit {
|
|||
throw new Error('Delete operation returned false');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting clipboard image:', error);
|
||||
Log.err('Error deleting clipboard image:', error);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: this._translateService.instant(T.GCF.CLIPBOARD_IMAGES.ERROR_DELETING),
|
||||
|
|
@ -159,7 +160,7 @@ export class DialogClipboardImagesManagerComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting all clipboard images:', error);
|
||||
Log.err('Error deleting all clipboard images:', error);
|
||||
await this.loadImages();
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
|
|
|
|||
|
|
@ -160,6 +160,10 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
taskOpenNotesFullscreen: null,
|
||||
taskOpenEstimationDialog: 'T',
|
||||
taskSchedule: 'S',
|
||||
taskScheduleToday: 'Shift+T',
|
||||
taskScheduleTomorrow: null,
|
||||
taskScheduleNextWeek: null,
|
||||
taskScheduleNextMonth: null,
|
||||
taskScheduleDeadline: 'Shift+S',
|
||||
taskUnschedule: 'U',
|
||||
taskToggleDone: 'D',
|
||||
|
|
@ -175,7 +179,6 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
moveTaskToTop: 'Ctrl+Alt+ArrowUp',
|
||||
moveTaskToBottom: 'Ctrl+Alt+ArrowDown',
|
||||
moveToBacklog: 'Shift+B',
|
||||
moveToTodaysTasks: 'Shift+T',
|
||||
expandSubTasks: null,
|
||||
collapseSubTasks: null,
|
||||
togglePlay: 'Y',
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection<KeyboardConfig> = {
|
|||
kbField('taskOpenNotesPanel', T.GCF.KEYBOARD.TASK_OPEN_NOTES_PANEL),
|
||||
kbField('taskOpenEstimationDialog', T.GCF.KEYBOARD.TASK_OPEN_ESTIMATION_DIALOG),
|
||||
kbField('taskSchedule', T.GCF.KEYBOARD.TASK_SCHEDULE),
|
||||
kbField('taskScheduleToday', T.GCF.KEYBOARD.TASK_SCHEDULE_TODAY),
|
||||
kbField('taskScheduleTomorrow', T.GCF.KEYBOARD.TASK_SCHEDULE_TOMORROW),
|
||||
kbField('taskScheduleNextWeek', T.GCF.KEYBOARD.TASK_SCHEDULE_NEXT_WEEK),
|
||||
kbField('taskScheduleNextMonth', T.GCF.KEYBOARD.TASK_SCHEDULE_NEXT_MONTH),
|
||||
kbField('taskScheduleDeadline', T.GCF.KEYBOARD.TASK_SCHEDULE_DEADLINE),
|
||||
kbField('taskUnschedule', T.GCF.KEYBOARD.TASK_UNSCHEDULE),
|
||||
kbField('taskToggleDone', T.GCF.KEYBOARD.TASK_TOGGLE_DONE),
|
||||
|
|
@ -113,7 +117,6 @@ export const KEYBOARD_SETTINGS_FORM_CFG: ConfigFormSection<KeyboardConfig> = {
|
|||
kbField('moveTaskToTop', T.GCF.KEYBOARD.MOVE_TASK_TO_TOP),
|
||||
kbField('moveTaskToBottom', T.GCF.KEYBOARD.MOVE_TASK_TO_BOTTOM),
|
||||
kbField('moveToBacklog', T.GCF.KEYBOARD.MOVE_TO_BACKLOG),
|
||||
kbField('moveToTodaysTasks', T.GCF.KEYBOARD.MOVE_TO_REGULARS_TASKS),
|
||||
kbField('expandSubTasks', T.GCF.KEYBOARD.EXPAND_SUB_TASKS),
|
||||
kbField('collapseSubTasks', T.GCF.KEYBOARD.COLLAPSE_SUB_TASKS),
|
||||
kbField('togglePlay', T.GCF.KEYBOARD.TOGGLE_PLAY),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { containsEmoji, extractFirstEmoji } from '../../../util/extract-first-em
|
|||
import { isSingleEmoji } from '../../../util/extract-first-emoji';
|
||||
import { startWith } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
@Component({
|
||||
selector: 'icon-input',
|
||||
|
|
@ -80,7 +81,7 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> implements
|
|||
this.filteredIcons.set(icons.slice(0, 50));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load material icons:', error);
|
||||
Log.err('Failed to load material icons:', error);
|
||||
this.filteredIcons.set([]);
|
||||
}
|
||||
}
|
||||
|
|
@ -123,7 +124,7 @@ export class IconInputComponent extends FieldType<FormlyFieldConfig> implements
|
|||
this.isEmoji.set(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to filter icons:', error);
|
||||
Log.err('Failed to filter icons:', error);
|
||||
this.filteredIcons.set([]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ export type KeyboardConfig = Readonly<{
|
|||
taskOpenContextMenu?: string | null;
|
||||
taskDelete?: string | null;
|
||||
taskSchedule?: string | null;
|
||||
taskScheduleToday?: string | null;
|
||||
taskScheduleTomorrow?: string | null;
|
||||
taskScheduleNextWeek?: string | null;
|
||||
taskScheduleNextMonth?: string | null;
|
||||
taskScheduleDeadline?: string | null;
|
||||
taskUnschedule?: string | null;
|
||||
selectPreviousTask?: string | null;
|
||||
|
|
@ -46,7 +50,6 @@ export type KeyboardConfig = Readonly<{
|
|||
moveTaskToTop?: string | null;
|
||||
moveTaskToBottom?: string | null;
|
||||
moveToBacklog?: string | null;
|
||||
moveToTodaysTasks?: string | null;
|
||||
expandSubTasks?: string | null;
|
||||
collapseSubTasks?: string | null;
|
||||
togglePlay?: string | null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import {
|
||||
applyLocalOnlySyncSettingsToAppData,
|
||||
LOCAL_ONLY_SYNC_DEVICE_KEYS,
|
||||
LOCAL_ONLY_SYNC_KEYS,
|
||||
LOCAL_ONLY_SYNC_SCHEDULE_KEYS,
|
||||
stripLocalOnlySyncScheduleSettings,
|
||||
stripLocalOnlySyncSettingsFromAppData,
|
||||
} from './local-only-sync-settings.util';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
|
||||
describe('local-only sync settings utils', () => {
|
||||
it('schedule and device key sets are disjoint and exhaust LOCAL_ONLY_SYNC_KEYS', () => {
|
||||
const union = new Set<string>([
|
||||
...LOCAL_ONLY_SYNC_SCHEDULE_KEYS,
|
||||
...LOCAL_ONLY_SYNC_DEVICE_KEYS,
|
||||
]);
|
||||
expect(union.size).toBe(
|
||||
LOCAL_ONLY_SYNC_SCHEDULE_KEYS.length + LOCAL_ONLY_SYNC_DEVICE_KEYS.length,
|
||||
);
|
||||
expect(union.size).toBe(LOCAL_ONLY_SYNC_KEYS.length);
|
||||
});
|
||||
|
||||
it('should strip sync schedule settings from a sync config object', () => {
|
||||
const result = stripLocalOnlySyncScheduleSettings({
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
isCompressionEnabled: true,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
expect(result).toEqual({
|
||||
isCompressionEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should strip local-only sync settings from app data', () => {
|
||||
const result = stripLocalOnlySyncSettingsFromAppData({
|
||||
globalConfig: {
|
||||
sync: {
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
misc: { isDisableAnimations: true },
|
||||
},
|
||||
task: { ids: [] },
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const globalConfig = result['globalConfig'] as Record<string, unknown>;
|
||||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
|
||||
expect(sync['syncProvider']).toBeNull();
|
||||
expect(sync['syncInterval']).toBeUndefined();
|
||||
expect(sync['isManualSyncOnly']).toBeUndefined();
|
||||
expect(sync['isCompressionEnabled']).toBe(true);
|
||||
expect(globalConfig['misc']).toEqual({ isDisableAnimations: true });
|
||||
expect(result['task']).toEqual({ ids: [] });
|
||||
});
|
||||
|
||||
it('should leave data without globalConfig.sync unchanged by reference', () => {
|
||||
const data = { task: { ids: [] } };
|
||||
|
||||
expect(stripLocalOnlySyncSettingsFromAppData(data)).toBe(data);
|
||||
});
|
||||
|
||||
it('should apply local-only sync settings to app data', () => {
|
||||
const result = applyLocalOnlySyncSettingsToAppData(
|
||||
{
|
||||
globalConfig: {
|
||||
sync: {
|
||||
syncProvider: SyncProviderId.Dropbox,
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: false,
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
isEnabled: true,
|
||||
isEncryptionEnabled: false,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
},
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const globalConfig = result['globalConfig'] as Record<string, unknown>;
|
||||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
|
||||
expect(sync['isEnabled']).toBe(true);
|
||||
expect(sync['syncProvider']).toBe(SyncProviderId.WebDAV);
|
||||
expect(sync['syncInterval']).toBe(300000);
|
||||
expect(sync['isManualSyncOnly']).toBe(true);
|
||||
expect(sync['isCompressionEnabled']).toBe(true);
|
||||
});
|
||||
});
|
||||
93
src/app/features/config/local-only-sync-settings.util.ts
Normal file
93
src/app/features/config/local-only-sync-settings.util.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { SyncConfig } from './global-config.model';
|
||||
|
||||
/**
|
||||
* Local-only sync settings — two tiers, both per-device:
|
||||
*
|
||||
* - SCHEDULE keys (`syncInterval`, `isManualSyncOnly`): must NEVER leave the
|
||||
* device. Stripped (`Omit`-style) at every upload boundary.
|
||||
* - DEVICE-IDENTITY keys (`syncProvider`, `isEnabled`, `isEncryptionEnabled`):
|
||||
* exist on every device but the local value must win on hydration. On upload
|
||||
* `syncProvider` is nulled (so a remote import never picks a provider for
|
||||
* you); on hydration the local values are re-applied via
|
||||
* {@link applyLocalOnlySyncSettingsToAppData}.
|
||||
*
|
||||
* The key arrays below are the single source of truth — both the
|
||||
* `LocalOnlySyncSettings` type and the reducer-side preservation helper
|
||||
* (`withLocalOnlySyncSettings` in `global-config.reducer.ts`) derive from them.
|
||||
* Add a new local-only key here and the type + reducer + strip helpers pick it
|
||||
* up automatically.
|
||||
*/
|
||||
export const LOCAL_ONLY_SYNC_SCHEDULE_KEYS = [
|
||||
'syncInterval',
|
||||
'isManualSyncOnly',
|
||||
] as const satisfies readonly (keyof SyncConfig)[];
|
||||
|
||||
export const LOCAL_ONLY_SYNC_DEVICE_KEYS = [
|
||||
'syncProvider',
|
||||
'isEnabled',
|
||||
'isEncryptionEnabled',
|
||||
] as const satisfies readonly (keyof SyncConfig)[];
|
||||
|
||||
export const LOCAL_ONLY_SYNC_KEYS = [
|
||||
...LOCAL_ONLY_SYNC_SCHEDULE_KEYS,
|
||||
...LOCAL_ONLY_SYNC_DEVICE_KEYS,
|
||||
] as const;
|
||||
|
||||
export type LocalOnlySyncSettings = Pick<
|
||||
SyncConfig,
|
||||
(typeof LOCAL_ONLY_SYNC_KEYS)[number]
|
||||
>;
|
||||
|
||||
export const stripLocalOnlySyncScheduleSettings = <T extends Record<string, unknown>>(
|
||||
syncConfig: T,
|
||||
): Omit<T, (typeof LOCAL_ONLY_SYNC_SCHEDULE_KEYS)[number]> => {
|
||||
const stripped = { ...syncConfig };
|
||||
for (const key of LOCAL_ONLY_SYNC_SCHEDULE_KEYS) {
|
||||
delete stripped[key];
|
||||
}
|
||||
return stripped;
|
||||
};
|
||||
|
||||
const _updateSyncConfigInAppData = <T>(
|
||||
data: T,
|
||||
updateSyncConfig: (syncConfig: Record<string, unknown>) => Record<string, unknown>,
|
||||
): T => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return data;
|
||||
}
|
||||
|
||||
const typedData = data as Record<string, unknown>;
|
||||
if (!typedData['globalConfig'] || typeof typedData['globalConfig'] !== 'object') {
|
||||
return data;
|
||||
}
|
||||
|
||||
const globalConfig = typedData['globalConfig'] as Record<string, unknown>;
|
||||
if (!globalConfig['sync'] || typeof globalConfig['sync'] !== 'object') {
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
...typedData,
|
||||
globalConfig: {
|
||||
...globalConfig,
|
||||
sync: updateSyncConfig(globalConfig['sync'] as Record<string, unknown>),
|
||||
},
|
||||
} as T;
|
||||
};
|
||||
|
||||
export const applyLocalOnlySyncSettingsToAppData = <T>(
|
||||
data: T,
|
||||
localOnlySettings: LocalOnlySyncSettings,
|
||||
): T => {
|
||||
return _updateSyncConfigInAppData(data, (syncConfig) => ({
|
||||
...syncConfig,
|
||||
...localOnlySettings,
|
||||
}));
|
||||
};
|
||||
|
||||
export const stripLocalOnlySyncSettingsFromAppData = (data: unknown): unknown => {
|
||||
return _updateSyncConfigInAppData(data, (syncConfig) => ({
|
||||
...stripLocalOnlySyncScheduleSettings(syncConfig),
|
||||
syncProvider: null,
|
||||
}));
|
||||
};
|
||||
|
|
@ -29,6 +29,7 @@ import { AppStateActions } from '../../../root-store/app-state/app-state.actions
|
|||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { selectAllTasks } from '../../tasks/store/task.selectors';
|
||||
import { normalizeStartOfNextDayConfig } from '../normalize-start-of-next-day-config';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalConfigEffects {
|
||||
|
|
@ -243,7 +244,7 @@ export class GlobalConfigEffects {
|
|||
setTimeout(() => window.location.reload(), 1000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to migrate user profiles:', err);
|
||||
Log.err('Failed to migrate user profiles:', err);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'Failed to enable user profiles. Please try again.',
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ import {
|
|||
selectIsFocusModeEnabled,
|
||||
selectTimelineWorkStartEndHours,
|
||||
} from './global-config.reducer';
|
||||
import { updateGlobalConfigSection } from './global-config.actions';
|
||||
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
|
||||
import { GlobalConfigState } from '../global-config.model';
|
||||
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
|
||||
import { AppDataComplete } from '../../../op-log/model/model-config';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const';
|
||||
import { LOCAL_ONLY_SYNC_KEYS } from '../local-only-sync-settings.util';
|
||||
import { INBOX_PROJECT } from '../../project/project.const';
|
||||
|
||||
describe('GlobalConfigReducer', () => {
|
||||
|
|
@ -234,6 +236,98 @@ describe('GlobalConfigReducer', () => {
|
|||
expect(result.keyboard.addNewNote).toBe('N');
|
||||
expect(result.keyboard.taskOpenNotesPanel).toBe('Alt+Shift+N');
|
||||
});
|
||||
|
||||
describe('moveToTodaysTasks migration', () => {
|
||||
it('should migrate moveToTodaysTasks to taskScheduleToday', () => {
|
||||
const legacyConfig = {
|
||||
...initialGlobalConfigState,
|
||||
keyboard: {
|
||||
...initialGlobalConfigState.keyboard,
|
||||
moveToTodaysTasks: 'Shift+T',
|
||||
taskScheduleToday: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = globalConfigReducer(
|
||||
initialGlobalConfigState,
|
||||
loadAllData({
|
||||
appDataComplete: {
|
||||
globalConfig: legacyConfig,
|
||||
} as unknown as AppDataComplete,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.keyboard.taskScheduleToday).toBe('Shift+T');
|
||||
expect((result.keyboard as any).moveToTodaysTasks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT re-migrate if taskScheduleToday is already null (manually disabled)', () => {
|
||||
const legacyConfigWithBoth = {
|
||||
...initialGlobalConfigState,
|
||||
keyboard: {
|
||||
...initialGlobalConfigState.keyboard,
|
||||
moveToTodaysTasks: 'Shift+T',
|
||||
taskScheduleToday: null,
|
||||
},
|
||||
};
|
||||
|
||||
// First migration
|
||||
const result1 = globalConfigReducer(
|
||||
initialGlobalConfigState,
|
||||
loadAllData({
|
||||
appDataComplete: {
|
||||
globalConfig: legacyConfigWithBoth,
|
||||
} as unknown as AppDataComplete,
|
||||
}),
|
||||
);
|
||||
expect(result1.keyboard.taskScheduleToday).toBe('Shift+T');
|
||||
expect((result1.keyboard as any).moveToTodaysTasks).toBeUndefined();
|
||||
|
||||
// User disables it
|
||||
const configWithDisabled = {
|
||||
...result1,
|
||||
keyboard: {
|
||||
...result1.keyboard,
|
||||
taskScheduleToday: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Second load (e.g. restart)
|
||||
const result2 = globalConfigReducer(
|
||||
initialGlobalConfigState,
|
||||
loadAllData({
|
||||
appDataComplete: {
|
||||
globalConfig: configWithDisabled,
|
||||
} as unknown as AppDataComplete,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result2.keyboard.taskScheduleToday).toBeNull();
|
||||
});
|
||||
|
||||
it('should strip moveToTodaysTasks even if no migration is needed', () => {
|
||||
const legacyConfig = {
|
||||
...initialGlobalConfigState,
|
||||
keyboard: {
|
||||
...initialGlobalConfigState.keyboard,
|
||||
moveToTodaysTasks: 'Shift+T',
|
||||
taskScheduleToday: 'Ctrl+T',
|
||||
},
|
||||
};
|
||||
|
||||
const result = globalConfigReducer(
|
||||
initialGlobalConfigState,
|
||||
loadAllData({
|
||||
appDataComplete: {
|
||||
globalConfig: legacyConfig,
|
||||
} as unknown as AppDataComplete,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.keyboard.taskScheduleToday).toBe('Ctrl+T');
|
||||
expect((result.keyboard as any).moveToTodaysTasks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should use syncProvider from snapshot when oldState has null (initial load)', () => {
|
||||
|
|
@ -444,13 +538,14 @@ describe('GlobalConfigReducer', () => {
|
|||
expect(result.misc.startOfNextDayTime).toBe('00:00');
|
||||
});
|
||||
|
||||
it('should update other sync config properties while preserving syncProvider', () => {
|
||||
it('should update shared sync config properties while preserving local-only ones', () => {
|
||||
const oldState: GlobalConfigState = {
|
||||
...initialGlobalConfigState,
|
||||
sync: {
|
||||
...initialGlobalConfigState.sync,
|
||||
syncProvider: SyncProviderId.SuperSync,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -460,6 +555,7 @@ describe('GlobalConfigReducer', () => {
|
|||
...initialGlobalConfigState.sync,
|
||||
syncProvider: null,
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: false,
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -471,10 +567,11 @@ describe('GlobalConfigReducer', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
// syncProvider preserved
|
||||
// Local-only settings preserved
|
||||
expect(result.sync.syncProvider).toBe(SyncProviderId.SuperSync);
|
||||
// Other sync settings updated
|
||||
expect(result.sync.syncInterval).toBe(600000);
|
||||
expect(result.sync.syncInterval).toBe(300000);
|
||||
expect(result.sync.isManualSyncOnly).toBe(true);
|
||||
// Shared sync settings updated
|
||||
expect(result.sync.isCompressionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -593,6 +690,62 @@ describe('GlobalConfigReducer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('local-only sync schedule settings preservation', () => {
|
||||
it('should use sync schedule settings from snapshot on initial load', () => {
|
||||
const snapshotConfig: GlobalConfigState = {
|
||||
...initialGlobalConfigState,
|
||||
sync: {
|
||||
...initialGlobalConfigState.sync,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = globalConfigReducer(
|
||||
initialGlobalConfigState,
|
||||
loadAllData({
|
||||
appDataComplete: { globalConfig: snapshotConfig } as AppDataComplete,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.sync.syncInterval).toBe(600000);
|
||||
expect(result.sync.isManualSyncOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve local sync schedule settings during sync hydration', () => {
|
||||
const oldState: GlobalConfigState = {
|
||||
...initialGlobalConfigState,
|
||||
sync: {
|
||||
...initialGlobalConfigState.sync,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
const syncedConfig: GlobalConfigState = {
|
||||
...initialGlobalConfigState,
|
||||
sync: {
|
||||
...initialGlobalConfigState.sync,
|
||||
syncProvider: null,
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = globalConfigReducer(
|
||||
oldState,
|
||||
loadAllData({
|
||||
appDataComplete: { globalConfig: syncedConfig } as AppDataComplete,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.sync.syncInterval).toBe(300000);
|
||||
expect(result.sync.isManualSyncOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('focusMode migration: isSyncSessionWithTracking → autoStartFocusOnPlay', () => {
|
||||
// Real persisted JSON never carries `autoStartFocusOnPlay` (it didn't
|
||||
// exist pre-rework). Constructing the fixture as an Object.assign so the
|
||||
|
|
@ -710,6 +863,185 @@ describe('GlobalConfigReducer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('updateGlobalConfigSection action', () => {
|
||||
it('should update sync schedule settings for local actions', () => {
|
||||
const result = globalConfigReducer(
|
||||
initialGlobalConfigState,
|
||||
updateGlobalConfigSection({
|
||||
sectionKey: 'sync',
|
||||
sectionCfg: {
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.sync.syncInterval).toBe(600000);
|
||||
expect(result.sync.isManualSyncOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve local-only sync settings for remote sync section updates', () => {
|
||||
const oldState: GlobalConfigState = {
|
||||
...initialGlobalConfigState,
|
||||
sync: {
|
||||
...initialGlobalConfigState.sync,
|
||||
isEnabled: true,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
isEncryptionEnabled: true,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
isCompressionEnabled: false,
|
||||
},
|
||||
};
|
||||
const remoteAction = updateGlobalConfigSection({
|
||||
sectionKey: 'sync',
|
||||
sectionCfg: {
|
||||
isEnabled: false,
|
||||
syncProvider: SyncProviderId.LocalFile,
|
||||
isEncryptionEnabled: false,
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: false,
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
});
|
||||
const remoteReplayAction = {
|
||||
...remoteAction,
|
||||
meta: {
|
||||
...remoteAction.meta,
|
||||
isRemote: true,
|
||||
isApplyingFromOtherClient: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = globalConfigReducer(oldState, remoteReplayAction);
|
||||
|
||||
expect(result.sync.isEnabled).toBe(true);
|
||||
expect(result.sync.syncProvider).toBe(SyncProviderId.WebDAV);
|
||||
expect(result.sync.isEncryptionEnabled).toBe(true);
|
||||
expect(result.sync.syncInterval).toBe(300000);
|
||||
expect(result.sync.isManualSyncOnly).toBe(true);
|
||||
expect(result.sync.isCompressionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
// Round-trip pin (issue #8233): iterates LOCAL_ONLY_SYNC_KEYS so adding a
|
||||
// new local-only key grows coverage here automatically.
|
||||
it('preserves every LOCAL_ONLY_SYNC_KEYS value on remote section updates (round-trip)', () => {
|
||||
const localSync = {
|
||||
...initialGlobalConfigState.sync,
|
||||
isEnabled: true,
|
||||
isEncryptionEnabled: true,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
};
|
||||
const remoteSync = {
|
||||
isEnabled: false,
|
||||
isEncryptionEnabled: false,
|
||||
syncProvider: SyncProviderId.Dropbox,
|
||||
syncInterval: 60000,
|
||||
isManualSyncOnly: false,
|
||||
};
|
||||
const oldState: GlobalConfigState = {
|
||||
...initialGlobalConfigState,
|
||||
sync: localSync,
|
||||
};
|
||||
const remoteAction = updateGlobalConfigSection({
|
||||
sectionKey: 'sync',
|
||||
sectionCfg: remoteSync,
|
||||
});
|
||||
const remoteReplayAction = {
|
||||
...remoteAction,
|
||||
meta: {
|
||||
...remoteAction.meta,
|
||||
isRemote: true,
|
||||
isApplyingFromOtherClient: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = globalConfigReducer(oldState, remoteReplayAction);
|
||||
|
||||
for (const key of LOCAL_ONLY_SYNC_KEYS) {
|
||||
expect(result.sync[key])
|
||||
.withContext(`sync.${key} must survive remote section update`)
|
||||
.toBe(localSync[key]);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression (scheduled e2e #8077): replaying the device's OWN sync-setup op
|
||||
// during hydration is stamped isRemote (to prevent re-logging) but is NOT a
|
||||
// foreign update. If the crash snapshot predates the setup op, local
|
||||
// state.sync.syncProvider is still null at replay time. Keying the local-only
|
||||
// preservation off isRemote (the #8077 bug) overwrote the op's real provider
|
||||
// with null and silently disabled sync. The bulk meta-reducer sets
|
||||
// isApplyingFromOtherClient ONLY for ops authored by a DIFFERENT client, so
|
||||
// own-op replay (isRemote without that flag) must apply the op faithfully.
|
||||
it('applies own-op replay faithfully when isRemote is set without isApplyingFromOtherClient', () => {
|
||||
const oldState: GlobalConfigState = {
|
||||
...initialGlobalConfigState,
|
||||
sync: {
|
||||
...initialGlobalConfigState.sync,
|
||||
// Mid-hydration: snapshot predates the setup op → provider not set yet.
|
||||
syncProvider: null,
|
||||
isEnabled: false,
|
||||
isEncryptionEnabled: false,
|
||||
},
|
||||
};
|
||||
const ownSetupAction = updateGlobalConfigSection({
|
||||
sectionKey: 'sync',
|
||||
sectionCfg: {
|
||||
isEnabled: true,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
isEncryptionEnabled: true,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
},
|
||||
});
|
||||
const ownReplayAction = {
|
||||
...ownSetupAction,
|
||||
meta: { ...ownSetupAction.meta, isRemote: true },
|
||||
};
|
||||
|
||||
const result = globalConfigReducer(oldState, ownReplayAction);
|
||||
|
||||
// The op's own values win — sync is NOT silently disabled.
|
||||
expect(result.sync.syncProvider).toBe(SyncProviderId.WebDAV);
|
||||
expect(result.sync.isEnabled).toBe(true);
|
||||
expect(result.sync.isEncryptionEnabled).toBe(true);
|
||||
expect(result.sync.syncInterval).toBe(300000);
|
||||
expect(result.sync.isManualSyncOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should update shared sync settings for remote sync section updates', () => {
|
||||
const remoteAction = updateGlobalConfigSection({
|
||||
sectionKey: 'sync',
|
||||
sectionCfg: {
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
});
|
||||
const remoteReplayAction = {
|
||||
...remoteAction,
|
||||
meta: {
|
||||
...remoteAction.meta,
|
||||
isRemote: true,
|
||||
isApplyingFromOtherClient: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = globalConfigReducer(
|
||||
{
|
||||
...initialGlobalConfigState,
|
||||
sync: {
|
||||
...initialGlobalConfigState.sync,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
},
|
||||
},
|
||||
remoteReplayAction,
|
||||
);
|
||||
|
||||
expect(result.sync.isCompressionEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default misc config (#7891)', () => {
|
||||
it('should NOT persist a default isUseCustomWindowTitleBar', () => {
|
||||
// Guard: a concrete default here would be pushed to Electron on every launch
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ import {
|
|||
FocusModeConfig,
|
||||
GlobalConfigState,
|
||||
MiscConfig,
|
||||
SyncConfig,
|
||||
} from '../global-config.model';
|
||||
import type { KeyboardConfig } from '../keyboard-config.model';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../default-global-config.const';
|
||||
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
|
||||
import { getHoursFromClockString } from '../../../util/get-hours-from-clock-string';
|
||||
import { normalizeStartOfNextDayConfig } from '../normalize-start-of-next-day-config';
|
||||
import { LOCAL_ONLY_SYNC_KEYS } from '../local-only-sync-settings.util';
|
||||
|
||||
/**
|
||||
* Migrate the legacy `isSyncSessionWithTracking` flag (removed in the focus-mode
|
||||
|
|
@ -106,14 +108,24 @@ export const initialGlobalConfigState: GlobalConfigState = {
|
|||
};
|
||||
|
||||
const migrateKeyboardConfig = (cfg: KeyboardConfig | undefined): KeyboardConfig => {
|
||||
const keyboard: KeyboardConfig = {
|
||||
const { moveToTodaysTasks, ...rest } =
|
||||
(cfg as (KeyboardConfig & { moveToTodaysTasks?: string | null }) | undefined) ?? {};
|
||||
|
||||
let keyboard: KeyboardConfig = {
|
||||
...DEFAULT_GLOBAL_CONFIG.keyboard,
|
||||
...cfg,
|
||||
...rest,
|
||||
};
|
||||
|
||||
if (moveToTodaysTasks != null && rest.taskScheduleToday == null) {
|
||||
keyboard = {
|
||||
...keyboard,
|
||||
taskScheduleToday: moveToTodaysTasks,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
cfg?.addNewNote === 'N' &&
|
||||
(cfg.taskOpenNotesPanel === undefined || cfg.taskOpenNotesPanel === null)
|
||||
rest.addNewNote === 'N' &&
|
||||
(rest.taskOpenNotesPanel === undefined || rest.taskOpenNotesPanel === null)
|
||||
) {
|
||||
return {
|
||||
...keyboard,
|
||||
|
|
@ -125,6 +137,20 @@ const migrateKeyboardConfig = (cfg: KeyboardConfig | undefined): KeyboardConfig
|
|||
return keyboard;
|
||||
};
|
||||
|
||||
// Overwrite every local-only key on the incoming config with the local value.
|
||||
// Keys are sourced from LOCAL_ONLY_SYNC_KEYS so adding a new local-only key in
|
||||
// local-only-sync-settings.util.ts automatically preserves it here too.
|
||||
const withLocalOnlySyncSettings = (
|
||||
incomingSyncConfig: SyncConfig,
|
||||
localSyncConfig: SyncConfig,
|
||||
): SyncConfig => {
|
||||
const merged = { ...incomingSyncConfig } as Record<string, unknown>;
|
||||
for (const key of LOCAL_ONLY_SYNC_KEYS) {
|
||||
merged[key] = localSyncConfig[key];
|
||||
}
|
||||
return merged as SyncConfig;
|
||||
};
|
||||
|
||||
export const globalConfigReducer = createReducer<GlobalConfigState>(
|
||||
initialGlobalConfigState,
|
||||
|
||||
|
|
@ -133,29 +159,26 @@ export const globalConfigReducer = createReducer<GlobalConfigState>(
|
|||
return oldState;
|
||||
}
|
||||
|
||||
const incomingSyncConfig = appDataComplete.globalConfig.sync;
|
||||
const incomingSyncConfig = {
|
||||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
...appDataComplete.globalConfig.sync,
|
||||
};
|
||||
|
||||
// Preserve local-only sync settings if they're already set.
|
||||
// These settings should remain local to each client:
|
||||
// - syncProvider: Each client can use different providers (Dropbox, WebDAV, etc.)
|
||||
// - isEnabled: Each client independently controls whether sync is enabled
|
||||
// - isEncryptionEnabled: Encryption state must not be overwritten by imports
|
||||
// - syncInterval: Each client chooses its own automatic sync frequency
|
||||
// - isManualSyncOnly: Each client chooses automatic vs manual sync
|
||||
//
|
||||
// If oldState.sync.syncProvider is null, we're on first load (using initialGlobalConfigState)
|
||||
// and should use the incoming values (from snapshot). Otherwise, preserve local values.
|
||||
const hasLocalSettings = oldState.sync.syncProvider !== null;
|
||||
|
||||
const syncProvider = hasLocalSettings
|
||||
? oldState.sync.syncProvider
|
||||
: incomingSyncConfig.syncProvider;
|
||||
|
||||
const isEnabled = hasLocalSettings
|
||||
? oldState.sync.isEnabled
|
||||
: incomingSyncConfig.isEnabled;
|
||||
|
||||
const isEncryptionEnabled = hasLocalSettings
|
||||
? oldState.sync.isEncryptionEnabled
|
||||
: incomingSyncConfig.isEncryptionEnabled;
|
||||
const syncConfig = hasLocalSettings
|
||||
? withLocalOnlySyncSettings(incomingSyncConfig, oldState.sync)
|
||||
: incomingSyncConfig;
|
||||
|
||||
const incomingGlobalConfig = {
|
||||
...DEFAULT_GLOBAL_CONFIG,
|
||||
|
|
@ -200,27 +223,38 @@ export const globalConfigReducer = createReducer<GlobalConfigState>(
|
|||
...migrateFocusModeConfig(appDataComplete.globalConfig.focusMode),
|
||||
},
|
||||
keyboard: migrateKeyboardConfig(appDataComplete.globalConfig.keyboard),
|
||||
sync: {
|
||||
...incomingSyncConfig,
|
||||
syncProvider,
|
||||
isEnabled,
|
||||
isEncryptionEnabled,
|
||||
},
|
||||
sync: syncConfig,
|
||||
};
|
||||
}),
|
||||
|
||||
on(updateGlobalConfigSection, (state, { sectionKey, sectionCfg }) => {
|
||||
on(updateGlobalConfigSection, (state, action) => {
|
||||
const { sectionKey, sectionCfg } = action;
|
||||
const normalizedSectionCfg =
|
||||
sectionKey === 'misc'
|
||||
? normalizeStartOfNextDayConfig(sectionCfg as Partial<MiscConfig>)
|
||||
: sectionCfg;
|
||||
const updatedSection = {
|
||||
...state[sectionKey],
|
||||
...normalizedSectionCfg,
|
||||
};
|
||||
|
||||
// Preserve this device's local-only sync settings ONLY when the update came
|
||||
// from ANOTHER client. `isRemote` is also true while replaying the device's
|
||||
// OWN ops during hydration — keying off it there would overwrite the op's
|
||||
// real (own) sync settings with whatever local state happens to be mid-replay
|
||||
// (e.g. a null syncProvider when the crash snapshot predates the setup op),
|
||||
// silently disabling sync. See bulkOperationsMetaReducer.
|
||||
const isFromOtherClient =
|
||||
(action as { meta?: { isApplyingFromOtherClient?: boolean } }).meta
|
||||
?.isApplyingFromOtherClient === true;
|
||||
const nextSection =
|
||||
sectionKey === 'sync' && isFromOtherClient
|
||||
? withLocalOnlySyncSettings(updatedSection as SyncConfig, state.sync)
|
||||
: updatedSection;
|
||||
|
||||
return {
|
||||
...state,
|
||||
[sectionKey]: {
|
||||
...state[sectionKey],
|
||||
...normalizedSectionCfg,
|
||||
},
|
||||
[sectionKey]: nextSection,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { CalendarContextInfoTarget } from '../../issue/providers/calendar/calend
|
|||
import { selectEnabledIssueProviders } from '../../issue/store/issue-provider.selectors';
|
||||
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
import { PluginService } from '../../../plugins/plugin.service';
|
||||
import { IssueLog } from '../../../core/log';
|
||||
|
||||
@Component({
|
||||
selector: 'issue-provider-setup-overview',
|
||||
|
|
@ -69,7 +70,7 @@ export class IssueProviderSetupOverviewComponent {
|
|||
this.pluginProviders = allProviders.filter((p) => !p.useAgendaView);
|
||||
this.pluginCalendarProviders = allProviders.filter((p) => p.useAgendaView);
|
||||
if (!isValidIssueProviderKey(issueProviderKey)) {
|
||||
console.error(`Invalid issue provider key from plugin: "${issueProviderKey}"`);
|
||||
IssueLog.err(`Invalid issue provider key from plugin: "${issueProviderKey}"`);
|
||||
return;
|
||||
}
|
||||
this.openSetupDialog(issueProviderKey);
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ export class DialogEditIssueProviderComponent {
|
|||
|
||||
constructor() {
|
||||
this._initOAuthAndOptions().catch((err) => {
|
||||
console.error(
|
||||
IssueLog.err(
|
||||
'[DialogEditIssueProvider] OAuth init failed',
|
||||
getSafeErrorLogMeta(err),
|
||||
);
|
||||
|
|
@ -453,7 +453,7 @@ export class DialogEditIssueProviderComponent {
|
|||
}
|
||||
} catch (e) {
|
||||
anyFailed = true;
|
||||
console.error('[DialogEditIssueProvider] loadOptions failed', {
|
||||
IssueLog.err('[DialogEditIssueProvider] loadOptions failed', {
|
||||
issueProviderKey: this.issueProviderKey,
|
||||
hasFieldKey: !!field.key,
|
||||
...getSafeErrorLogMeta(e),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
TestRequest,
|
||||
} from '@angular/common/http/testing';
|
||||
import { HttpRequest } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SnackService } from '../../../../core/snack/snack.service';
|
||||
import { SearchResultItem } from '../../issue.model';
|
||||
import { RedmineApiService } from './redmine-api.service';
|
||||
import { RedmineCfg } from './redmine.model';
|
||||
import { SearchResultItem } from '../../issue.model';
|
||||
import { SnackService } from '../../../../core/snack/snack.service';
|
||||
|
||||
describe('RedmineApiService', () => {
|
||||
let service: RedmineApiService;
|
||||
let httpMock: HttpTestingController;
|
||||
let snackService: jasmine.SpyObj<SnackService>;
|
||||
|
||||
const mockCfg: RedmineCfg = {
|
||||
isEnabled: true,
|
||||
|
|
@ -34,14 +36,15 @@ describe('RedmineApiService', () => {
|
|||
limit: 100,
|
||||
};
|
||||
|
||||
// by-id lookup is scoped to the configured project (not the global /issues/{id}.json)
|
||||
const searchUrl = (query: string): string =>
|
||||
`${mockCfg.host}/projects/${mockCfg.projectId}/search.json?limit=100&q=${query}&issues=1&open_issues=1`;
|
||||
|
||||
const byIdInProjectMatcher =
|
||||
(issueId: number) =>
|
||||
(req: HttpRequest<unknown>): boolean =>
|
||||
req.method === 'GET' &&
|
||||
req.url === `${mockCfg.host}/projects/${mockCfg.projectId}/issues.json` &&
|
||||
req.params.get('issue_id') === String(issueId) &&
|
||||
// status_id=* so closed issues are found too
|
||||
req.params.get('status_id') === '*';
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -57,6 +60,7 @@ describe('RedmineApiService', () => {
|
|||
});
|
||||
service = TestBed.inject(RedmineApiService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
snackService = TestBed.inject(SnackService) as jasmine.SpyObj<SnackService>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -68,10 +72,7 @@ describe('RedmineApiService', () => {
|
|||
});
|
||||
|
||||
describe('searchIssuesInProject$', () => {
|
||||
const searchUrl = (query: string): string =>
|
||||
`${mockCfg.host}/projects/${mockCfg.projectId}/search.json?limit=100&q=${query}&issues=1&open_issues=1`;
|
||||
|
||||
it('should only send a text search request for non-numeric queries', () => {
|
||||
it('should only send a text search request for non-numeric Latin queries', () => {
|
||||
let result: SearchResultItem[] | undefined;
|
||||
service.searchIssuesInProject$('some text', mockCfg).subscribe((r) => (result = r));
|
||||
|
||||
|
|
@ -132,7 +133,6 @@ describe('RedmineApiService', () => {
|
|||
service.searchIssuesInProject$('99999', mockCfg).subscribe((r) => (result = r));
|
||||
|
||||
const byIdReq = httpMock.expectOne(byIdInProjectMatcher(99999));
|
||||
// Redmine returns an empty list (not a 404) for an id outside the project
|
||||
byIdReq.flush({ issues: [], total_count: 0, offset: 0, limit: 1 });
|
||||
|
||||
const searchReq = httpMock.expectOne(searchUrl('99999'));
|
||||
|
|
@ -146,18 +146,90 @@ describe('RedmineApiService', () => {
|
|||
let result: SearchResultItem[] | undefined;
|
||||
service.searchIssuesInProject$('424242', mockCfg).subscribe((r) => (result = r));
|
||||
|
||||
// The lookup is scoped to the configured project; an id belonging to another
|
||||
// project the API key can see is therefore returned as an empty list by Redmine.
|
||||
const byIdReq = httpMock.expectOne(byIdInProjectMatcher(424242));
|
||||
byIdReq.flush({ issues: [], total_count: 0, offset: 0, limit: 1 });
|
||||
|
||||
const searchReq = httpMock.expectOne(searchUrl('424242'));
|
||||
searchReq.flush({ ...mockSearchResponse, results: [] });
|
||||
|
||||
// no global /issues/{id}.json request is ever made
|
||||
httpMock.expectNone(`${mockCfg.host}/issues/424242.json`);
|
||||
expect(result?.length).toBe(0);
|
||||
});
|
||||
|
||||
it('adds a subject filter fallback for non-Latin queries without full text results', () => {
|
||||
service.searchIssuesInProject$('修正', mockCfg).subscribe((issues) => {
|
||||
expect(issues.length).toBe(1);
|
||||
expect(issues[0].title).toBe('#23 修正ログイン');
|
||||
expect(issues[0].issueData.id).toBe(23);
|
||||
});
|
||||
|
||||
const searchReq = httpMock.expectOne(searchUrl('%E4%BF%AE%E6%AD%A3'));
|
||||
searchReq.flush({
|
||||
results: [],
|
||||
total_count: 0,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const fallbackReq = expectSubjectFallbackRequest(httpMock, mockCfg, '修正');
|
||||
expect(fallbackReq.request.method).toBe('GET');
|
||||
fallbackReq.flush({
|
||||
issues: [
|
||||
{
|
||||
id: 23,
|
||||
subject: '修正ログイン',
|
||||
title: '修正ログイン',
|
||||
updated_on: '2026-01-01T00:00:00Z',
|
||||
url: 'https://redmine.example.com/issues/23',
|
||||
},
|
||||
],
|
||||
total_count: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not request the subject fallback when full text search already returns results', () => {
|
||||
service.searchIssuesInProject$('修正', mockCfg).subscribe((issues) => {
|
||||
expect(issues.map((issue) => issue.issueData.id)).toEqual([23]);
|
||||
});
|
||||
|
||||
const searchReq = httpMock.expectOne(searchUrl('%E4%BF%AE%E6%AD%A3'));
|
||||
searchReq.flush({
|
||||
results: [
|
||||
{
|
||||
id: 23,
|
||||
title: '#23 修正ログイン',
|
||||
url: 'https://redmine.example.com/issues/23',
|
||||
},
|
||||
],
|
||||
total_count: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
httpMock.expectNone((req: HttpRequest<unknown>) => {
|
||||
return req.url === `${mockCfg.host}/projects/${mockCfg.projectId}/issues.json`;
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps full text results when subject fallback fails', () => {
|
||||
service.searchIssuesInProject$('修正', mockCfg).subscribe((issues) => {
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
const searchReq = httpMock.expectOne(searchUrl('%E4%BF%AE%E6%AD%A3'));
|
||||
searchReq.flush({
|
||||
results: [],
|
||||
total_count: 0,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const fallbackReq = expectSubjectFallbackRequest(httpMock, mockCfg, '修正');
|
||||
fallbackReq.flush('Forbidden', { status: 403, statusText: 'Forbidden' });
|
||||
expect(snackService.open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('global mode (no projectId)', () => {
|
||||
|
|
@ -249,5 +321,57 @@ describe('RedmineApiService', () => {
|
|||
|
||||
expect(result?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should run the non-Latin subject fallback against instance-wide /issues.json', () => {
|
||||
service.searchIssuesInProject$('修正', globalCfg).subscribe((issues) => {
|
||||
expect(issues.length).toBe(1);
|
||||
expect(issues[0].title).toBe('#23 修正ログイン');
|
||||
});
|
||||
|
||||
const searchReq = httpMock.expectOne(globalSearchUrl('%E4%BF%AE%E6%AD%A3'));
|
||||
searchReq.flush({
|
||||
results: [],
|
||||
total_count: 0,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const fallbackReq = expectSubjectFallbackRequest(httpMock, globalCfg, '修正');
|
||||
expect(fallbackReq.request.method).toBe('GET');
|
||||
fallbackReq.flush({
|
||||
issues: [
|
||||
{
|
||||
id: 23,
|
||||
subject: '修正ログイン',
|
||||
title: '修正ログイン',
|
||||
updated_on: '2026-01-01T00:00:00Z',
|
||||
url: 'https://redmine.example.com/issues/23',
|
||||
},
|
||||
],
|
||||
total_count: 1,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const expectSubjectFallbackRequest = (
|
||||
httpMock: HttpTestingController,
|
||||
cfg: RedmineCfg,
|
||||
query: string,
|
||||
): TestRequest =>
|
||||
httpMock.expectOne((req: HttpRequest<unknown>) => {
|
||||
const params = req.params;
|
||||
|
||||
return (
|
||||
req.url ===
|
||||
`${cfg.host}${cfg.projectId ? `/projects/${cfg.projectId}` : ''}/issues.json` &&
|
||||
params.get('limit') === '100' &&
|
||||
params.get('status_id') === 'open' &&
|
||||
params.get('set_filter') === '1' &&
|
||||
params.get('f[]') === 'subject' &&
|
||||
params.get('op[subject]') === '~' &&
|
||||
params.get('v[subject][]') === query
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
|||
import { SnackService } from '../../../../core/snack/snack.service';
|
||||
import { HttpClient, HttpHeaders, HttpParams, HttpRequest } from '@angular/common/http';
|
||||
import { RedmineCfg } from './redmine.model';
|
||||
import { catchError, filter, map } from 'rxjs/operators';
|
||||
import { catchError, filter, map, switchMap } from 'rxjs/operators';
|
||||
import { forkJoin, Observable, of, throwError } from 'rxjs';
|
||||
import { throwHandledError } from '../../../../util/throw-handled-error';
|
||||
import { T } from '../../../../t.const';
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
RedmineActivityResult,
|
||||
RedmineIssue,
|
||||
RedmineIssueResult,
|
||||
RedmineIssueStatusOptions,
|
||||
RedmineSearchResult,
|
||||
RedmineSearchResultItem,
|
||||
RedmineTimeEntriesResult,
|
||||
|
|
@ -36,48 +37,32 @@ export class RedmineApiService {
|
|||
private _http = inject(HttpClient);
|
||||
|
||||
searchIssuesInProject$(query: string, cfg: RedmineCfg): Observable<SearchResultItem[]> {
|
||||
const textSearch$: Observable<SearchResultItem[]> = this._sendRequest$(
|
||||
{
|
||||
url: `${cfg.host}${this._projectScopePath(cfg)}/search.json`,
|
||||
params: ParamsBuilder.create()
|
||||
.withLimit(100)
|
||||
.withQuery(query)
|
||||
.onlyIssues(true)
|
||||
.openIssues(true)
|
||||
.build(),
|
||||
},
|
||||
cfg,
|
||||
).pipe(
|
||||
map((res: RedmineSearchResult) => {
|
||||
return res
|
||||
? res.results.map((item: RedmineSearchResultItem) =>
|
||||
mapRedmineSearchResultItemToSearchResult(item),
|
||||
)
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
|
||||
// Redmine's search API only does full text search and does not match issue ids,
|
||||
// so for numeric queries (e.g. "1234" or "#1234") we additionally try to fetch
|
||||
// the issue by its id and merge it into the results.
|
||||
const searchResults$ = this._searchTextIssuesInProject$(query, cfg);
|
||||
const idMatch = query.trim().match(ISSUE_ID_QUERY_RGX);
|
||||
if (!idMatch) {
|
||||
return textSearch$;
|
||||
|
||||
if (!idMatch && !queryHasNonAscii(query)) {
|
||||
return searchResults$;
|
||||
}
|
||||
|
||||
const issueId = Number(idMatch[1]);
|
||||
return forkJoin([
|
||||
this._getIssueByIdInProject$(issueId, cfg).pipe(catchError(() => of(null))),
|
||||
textSearch$,
|
||||
]).pipe(
|
||||
map(([issueById, textResults]) =>
|
||||
issueById
|
||||
? [
|
||||
mapRedmineIssueToSearchResult(issueById),
|
||||
...textResults.filter((result) => result.issueData.id !== issueId),
|
||||
]
|
||||
: textResults,
|
||||
),
|
||||
const issueById$: Observable<RedmineIssue | null> = idMatch
|
||||
? this._getIssueByIdInProject$(Number(idMatch[1]), cfg).pipe(
|
||||
catchError(() => of(null)),
|
||||
)
|
||||
: of(null);
|
||||
|
||||
return forkJoin([issueById$, searchResults$]).pipe(
|
||||
switchMap(([issueById, searchResults]) => {
|
||||
const resultsWithId = issueById
|
||||
? mergeSearchResults([mapRedmineIssueToSearchResult(issueById)], searchResults)
|
||||
: searchResults;
|
||||
|
||||
return queryHasNonAscii(query) && resultsWithId.length === 0
|
||||
? this._searchIssuesBySubjectInProject$(query, cfg).pipe(
|
||||
map((subjectResults) => mergeSearchResults(resultsWithId, subjectResults)),
|
||||
catchError(() => of(resultsWithId)),
|
||||
)
|
||||
: of(resultsWithId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +84,7 @@ export class RedmineApiService {
|
|||
url: `${cfg.host}${this._projectScopePath(cfg)}/issues.json`,
|
||||
params: ParamsBuilder.create()
|
||||
.withParam('issue_id', String(issueId))
|
||||
.withState('*')
|
||||
.withState(RedmineIssueStatusOptions.all)
|
||||
.withLimit(1)
|
||||
.build(),
|
||||
},
|
||||
|
|
@ -189,6 +174,60 @@ export class RedmineApiService {
|
|||
);
|
||||
}
|
||||
|
||||
private _searchTextIssuesInProject$(
|
||||
query: string,
|
||||
cfg: RedmineCfg,
|
||||
): Observable<SearchResultItem[]> {
|
||||
return this._sendRequest$(
|
||||
{
|
||||
url: `${cfg.host}${this._projectScopePath(cfg)}/search.json`,
|
||||
params: ParamsBuilder.create()
|
||||
.withLimit(100)
|
||||
.withQuery(query)
|
||||
.onlyIssues(true)
|
||||
.openIssues(true)
|
||||
.build(),
|
||||
},
|
||||
cfg,
|
||||
).pipe(
|
||||
map((res: RedmineSearchResult) => {
|
||||
return res
|
||||
? res.results.map((item: RedmineSearchResultItem) =>
|
||||
mapRedmineSearchResultItemToSearchResult(item),
|
||||
)
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private _searchIssuesBySubjectInProject$(
|
||||
query: string,
|
||||
cfg: RedmineCfg,
|
||||
): Observable<SearchResultItem[]> {
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (!trimmedQuery) {
|
||||
return of([]);
|
||||
}
|
||||
|
||||
return this._sendRequest$(
|
||||
{
|
||||
url: `${cfg.host}${this._projectScopePath(cfg)}/issues.json`,
|
||||
params: ParamsBuilder.create()
|
||||
.withLimit(100)
|
||||
.withState(RedmineIssueStatusOptions.open)
|
||||
.withSubjectContains(trimmedQuery)
|
||||
.build(),
|
||||
},
|
||||
cfg,
|
||||
{ isSkipErrorHandling: true },
|
||||
).pipe(
|
||||
map((res: RedmineIssueResult) =>
|
||||
(res?.issues ?? []).map((issue) => mapRedmineIssueToSearchResult(issue)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the project URL segment to prefix scoped endpoints with: `/projects/<id>` when a
|
||||
// project is configured, or '' when it is not. An empty segment makes requests hit Redmine's
|
||||
// instance-wide endpoints (`/search.json`, `/issues.json`), so a single connection can search
|
||||
|
|
@ -266,6 +305,37 @@ export class RedmineApiService {
|
|||
}
|
||||
}
|
||||
|
||||
const queryHasNonAscii = (query: string): boolean => /[^\u0000-\u007F]/.test(query);
|
||||
|
||||
const getIssueId = (item: SearchResultItem): number | string | undefined => {
|
||||
return item.issueData?.id;
|
||||
};
|
||||
|
||||
const mergeSearchResults = (
|
||||
...resultGroups: SearchResultItem[][]
|
||||
): SearchResultItem[] => {
|
||||
const seenIssueIds = new Set<number | string>();
|
||||
const mergedResults: SearchResultItem[] = [];
|
||||
|
||||
for (const resultGroup of resultGroups) {
|
||||
for (const item of resultGroup) {
|
||||
const issueId = getIssueId(item);
|
||||
|
||||
if (issueId !== undefined && issueId !== null) {
|
||||
if (seenIssueIds.has(issueId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIssueIds.add(issueId);
|
||||
}
|
||||
|
||||
mergedResults.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return mergedResults;
|
||||
};
|
||||
|
||||
class ParamsBuilder {
|
||||
params: any = {};
|
||||
|
||||
|
|
@ -303,6 +373,14 @@ class ParamsBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
withSubjectContains(query: string): ParamsBuilder {
|
||||
this.params['set_filter'] = '1';
|
||||
this.params['f[]'] = 'subject';
|
||||
this.params['op[subject]'] = '~';
|
||||
this.params['v[subject][]'] = query;
|
||||
return this;
|
||||
}
|
||||
|
||||
onlyIssues(isOnlyIssues: boolean): ParamsBuilder {
|
||||
this.params['issues'] = isOnlyIssues ? '1' : '0';
|
||||
return this;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { ISSUE_PROVIDER_HUMANIZED, TRELLO_TYPE } from '../../issue.const';
|
|||
import { T } from '../../../../t.const';
|
||||
import { HANDLED_ERROR_PROP_STR } from '../../../../app.constants';
|
||||
import { getErrorTxt } from '../../../../util/get-error-text';
|
||||
import { IssueLog } from '../../../../core/log';
|
||||
|
||||
const BASE_URL = 'https://api.trello.com/1';
|
||||
const DEFAULT_CARD_FIELDS = [
|
||||
|
|
@ -195,7 +196,8 @@ export class TrelloApiService {
|
|||
}),
|
||||
catchError((error) => {
|
||||
const errorMsg = `Trello failed to resolve username "${username}": ${getErrorTxt(error)}`;
|
||||
console.error(errorMsg);
|
||||
// errorMsg embeds the username (user content) — keep it out of the exportable log
|
||||
IssueLog.err('Trello failed to resolve username:', getErrorTxt(error));
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: errorMsg,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { IssueSyncAdapterResolverService } from './issue-sync-adapter-resolver.service';
|
||||
import { IssueSyncAdapterRegistryService } from './issue-sync-adapter-registry.service';
|
||||
import { IssueSyncAdapter } from './issue-sync-adapter.interface';
|
||||
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
import { PluginHttpService } from '../../../plugins/issue-provider/plugin-http.service';
|
||||
import { TagService } from '../../tag/tag.service';
|
||||
|
||||
describe('IssueSyncAdapterResolverService', () => {
|
||||
let service: IssueSyncAdapterResolverService;
|
||||
let adapterRegistry: IssueSyncAdapterRegistryService;
|
||||
let pluginRegistry: jasmine.SpyObj<PluginIssueProviderRegistryService>;
|
||||
|
||||
const existingAdapter = {} as IssueSyncAdapter<unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
pluginRegistry = jasmine.createSpyObj<PluginIssueProviderRegistryService>(
|
||||
'PluginIssueProviderRegistryService',
|
||||
['getProvider'],
|
||||
);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
IssueSyncAdapterResolverService,
|
||||
IssueSyncAdapterRegistryService,
|
||||
{ provide: PluginIssueProviderRegistryService, useValue: pluginRegistry },
|
||||
{
|
||||
provide: PluginHttpService,
|
||||
useValue: jasmine.createSpyObj<PluginHttpService>('PluginHttpService', [
|
||||
'createHttpHelper',
|
||||
]),
|
||||
},
|
||||
{ provide: TagService, useValue: { tags: () => [] } },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(IssueSyncAdapterResolverService);
|
||||
adapterRegistry = TestBed.inject(IssueSyncAdapterRegistryService);
|
||||
});
|
||||
|
||||
it('returns an existing registered adapter without consulting plugin providers', () => {
|
||||
adapterRegistry.register('plugin:test', existingAdapter);
|
||||
|
||||
expect(service.getAdapter('plugin:test')).toBe(existingAdapter);
|
||||
expect(pluginRegistry.getProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lazily creates and registers plugin adapters for writable providers', () => {
|
||||
pluginRegistry.getProvider.and.returnValue({
|
||||
allowPrivateNetwork: true,
|
||||
definition: {
|
||||
getHeaders: () => ({}),
|
||||
updateIssue: jasmine.createSpy('updateIssue'),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const adapter = service.getAdapter('plugin:test');
|
||||
|
||||
expect(adapter).toBeTruthy();
|
||||
expect(adapterRegistry.get('plugin:test')).toBe(adapter);
|
||||
expect(adapter?.pushChanges).toEqual(jasmine.any(Function));
|
||||
});
|
||||
|
||||
it('does not create adapters for plugins without issue side effects', () => {
|
||||
pluginRegistry.getProvider.and.returnValue({
|
||||
definition: { getHeaders: () => ({}) },
|
||||
} as any);
|
||||
|
||||
expect(service.getAdapter('plugin:test')).toBeUndefined();
|
||||
expect(adapterRegistry.get('plugin:test')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
import { PluginHttpService } from '../../../plugins/issue-provider/plugin-http.service';
|
||||
import { TagService } from '../../tag/tag.service';
|
||||
import { createPluginSyncAdapter } from '../../../plugins/issue-provider/plugin-sync-adapter.service';
|
||||
import { IssueSyncAdapter } from './issue-sync-adapter.interface';
|
||||
import { IssueSyncAdapterRegistryService } from './issue-sync-adapter-registry.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class IssueSyncAdapterResolverService {
|
||||
private readonly _adapterRegistry = inject(IssueSyncAdapterRegistryService);
|
||||
private readonly _pluginRegistry = inject(PluginIssueProviderRegistryService);
|
||||
private readonly _pluginHttp = inject(PluginHttpService);
|
||||
private readonly _tagService = inject(TagService);
|
||||
|
||||
getAdapter(issueType: string): IssueSyncAdapter<unknown> | undefined {
|
||||
const existing = this._adapterRegistry.get(issueType);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const provider = this._pluginRegistry.getProvider(issueType);
|
||||
const definition = provider?.definition;
|
||||
if (
|
||||
!provider ||
|
||||
!definition ||
|
||||
// `updateIssue` alone is intentional: calendar moves can push direct
|
||||
// event changes even when a provider has no task field mappings.
|
||||
(!definition.createIssue && !definition.deleteIssue && !definition.updateIssue)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
(getHeadersFn) =>
|
||||
this._pluginHttp.createHttpHelper(getHeadersFn, {
|
||||
allowPrivateNetwork: provider.allowPrivateNetwork,
|
||||
}),
|
||||
this._tagService,
|
||||
);
|
||||
|
||||
this._adapterRegistry.register(issueType, adapter);
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { IssueTwoWaySyncEffects } from './issue-two-way-sync.effects';
|
|||
import { TaskService } from '../../tasks/task.service';
|
||||
import { IssueProviderService } from '../issue-provider.service';
|
||||
import { IssueSyncAdapterRegistryService } from './issue-sync-adapter-registry.service';
|
||||
import { IssueSyncAdapterResolverService } from './issue-sync-adapter-resolver.service';
|
||||
import { CaldavSyncAdapterService } from '../providers/caldav/caldav-sync-adapter.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
|
|
@ -136,6 +137,13 @@ describe('IssueTwoWaySyncEffects', () => {
|
|||
{ provide: IssueProviderService, useValue: issueProviderServiceSpy },
|
||||
{ provide: CaldavSyncAdapterService, useValue: caldavSpy },
|
||||
{ provide: SnackService, useValue: snackServiceSpy },
|
||||
{
|
||||
provide: IssueSyncAdapterResolverService,
|
||||
useFactory: (registry: IssueSyncAdapterRegistryService) => ({
|
||||
getAdapter: (issueType: string) => registry.get(issueType),
|
||||
}),
|
||||
deps: [IssueSyncAdapterRegistryService],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,12 +26,10 @@ import { DeletedTagTitlesSidecarService } from './deleted-tag-titles-sidecar.ser
|
|||
import { selectEnabledIssueProviders } from '../store/issue-provider.selectors';
|
||||
import { getErrorTxt } from '../../../util/get-error-text';
|
||||
import { T } from '../../../t.const';
|
||||
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
import { PluginHttpService } from '../../../plugins/issue-provider/plugin-http.service';
|
||||
import { TagService } from '../../tag/tag.service';
|
||||
import { createPluginSyncAdapter } from '../../../plugins/issue-provider/plugin-sync-adapter.service';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { deleteTag, deleteTags } from '../../tag/store/tag.actions';
|
||||
import { IssueSyncAdapterResolverService } from './issue-sync-adapter-resolver.service';
|
||||
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
|
||||
|
||||
const SYNCABLE_TASK_FIELDS: ReadonlySet<string> = new Set([
|
||||
'isDone',
|
||||
|
|
@ -124,9 +122,8 @@ export class IssueTwoWaySyncEffects {
|
|||
private readonly _snackService = inject(SnackService);
|
||||
private readonly _deletedTaskIssueSidecar = inject(DeletedTaskIssueSidecarService);
|
||||
private readonly _deletedTagTitlesSidecar = inject(DeletedTagTitlesSidecarService);
|
||||
private readonly _adapterResolver = inject(IssueSyncAdapterResolverService);
|
||||
private readonly _pluginRegistry = inject(PluginIssueProviderRegistryService);
|
||||
private readonly _pluginHttp = inject(PluginHttpService);
|
||||
private readonly _tagService = inject(TagService);
|
||||
private _syncOriginatedTaskIds = new Set<string>();
|
||||
private static readonly _MAX_SYNC_ORIGINATED_IDS = 1000;
|
||||
|
||||
|
|
@ -464,32 +461,7 @@ export class IssueTwoWaySyncEffects {
|
|||
}
|
||||
|
||||
private _getAdapter(issueType: string): IssueSyncAdapter<unknown> | undefined {
|
||||
const existing = this._adapterRegistry.get(issueType);
|
||||
if (existing) return existing;
|
||||
|
||||
const provider = this._pluginRegistry.getProvider(issueType);
|
||||
const definition = provider?.definition;
|
||||
if (
|
||||
!provider ||
|
||||
!definition ||
|
||||
(!definition.createIssue &&
|
||||
!definition.deleteIssue &&
|
||||
!(definition.fieldMappings?.length && definition.updateIssue))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const adapter = createPluginSyncAdapter(
|
||||
definition,
|
||||
(getHeadersFn) =>
|
||||
this._pluginHttp.createHttpHelper(getHeadersFn, {
|
||||
allowPrivateNetwork: provider.allowPrivateNetwork,
|
||||
}),
|
||||
this._tagService,
|
||||
);
|
||||
|
||||
this._adapterRegistry.register(issueType, adapter);
|
||||
return adapter;
|
||||
return this._adapterResolver.getAdapter(issueType);
|
||||
}
|
||||
|
||||
private _deleteRemoteIssue$(info: DeletedTaskIssueInfo | Task): Observable<unknown> {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from '../../../ui/heatmap/heatmap.component';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { Worklog } from '../../worklog/worklog.model';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
interface YearlyActivityData {
|
||||
dayMap: Map<string, DayData>;
|
||||
|
|
@ -368,7 +369,7 @@ export class ActivityHeatmapComponent {
|
|||
});
|
||||
}
|
||||
} else if (result.error && result.error !== 'Share cancelled') {
|
||||
console.error('Share failed:', result.error);
|
||||
Log.err('Share failed:', result.error);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'Failed to share heatmap',
|
||||
|
|
@ -377,7 +378,7 @@ export class ActivityHeatmapComponent {
|
|||
} catch (error: any) {
|
||||
const isAbort = error?.name === 'AbortError' || error?.error === 'Share cancelled';
|
||||
if (!isAbort) {
|
||||
console.error('Share failed:', error);
|
||||
Log.err('Share failed:', error);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: 'Failed to share heatmap',
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { ChartLazyLoaderService } from '../chart-lazy-loader.service';
|
|||
import { ShareService } from '../../../core/share/share.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { T } from '../../../t.const';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
interface ChartClickEvent {
|
||||
active: ActiveElement[];
|
||||
|
|
@ -226,10 +227,10 @@ export class LazyChartComponent implements OnDestroy {
|
|||
}
|
||||
|
||||
if (!result.success && result.error && result.error !== 'Share cancelled') {
|
||||
console.error('Share failed:', result.error);
|
||||
Log.err('Share failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Share failed:', error);
|
||||
Log.err('Share failed:', error);
|
||||
} finally {
|
||||
this.isSharing.set(false);
|
||||
}
|
||||
|
|
@ -247,14 +248,14 @@ export class LazyChartComponent implements OnDestroy {
|
|||
await this.chartLoaderService.loadChartModule();
|
||||
this.isModuleLoaded.set(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load chart module:', error);
|
||||
Log.err('Failed to load chart module:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private initChart(canvas: ElementRef<HTMLCanvasElement>): void {
|
||||
const chartModule = this.chartLoaderService.getChartModule();
|
||||
if (!chartModule) {
|
||||
console.error('Chart module not loaded');
|
||||
Log.err('Chart module not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -269,7 +270,7 @@ export class LazyChartComponent implements OnDestroy {
|
|||
this.chartInstance?.destroy();
|
||||
this.chartInstance = new Chart(canvas.nativeElement, chartConfig);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize chart:', error);
|
||||
Log.err('Failed to initialize chart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
177
src/app/features/planner/store/planner.bug-8220.spec.ts
Normal file
177
src/app/features/planner/store/planner.bug-8220.spec.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import * as fromSelectors from './planner.selectors';
|
||||
import { PlannerState } from './planner.reducer';
|
||||
import { Task } from '../../tasks/task.model';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import {
|
||||
DEFAULT_TASK_REPEAT_CFG,
|
||||
TaskRepeatCfg,
|
||||
} from '../../task-repeat-cfg/task-repeat-cfg.model';
|
||||
|
||||
// Regression for #8220: a recurring task that already has an instance in a day
|
||||
// (created, and possibly marked done) was counted twice in the Planner: once as
|
||||
// the real task instance and again as a repeat projection. The "Today" view
|
||||
// (work-context.util) only ever counted the real instance, so the two views
|
||||
// disagreed on remaining time. The Planner must defer to the real instance and
|
||||
// drop the now-redundant projection.
|
||||
describe('Planner Selectors - #8220 recurring done task double-count', () => {
|
||||
const today = getDbDateStr();
|
||||
const ONE_HOUR = 60 * 60 * 1000;
|
||||
|
||||
const createMockTask = (overrides: Partial<Task> & { id: string }): Task => {
|
||||
const { id, ...rest } = overrides;
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
created: Date.now(),
|
||||
isDone: false,
|
||||
subTaskIds: [],
|
||||
tagIds: [],
|
||||
projectId: 'project1',
|
||||
timeSpentOnDay: {},
|
||||
timeEstimate: 0,
|
||||
timeSpent: 0,
|
||||
attachments: [],
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
const createTasksMapFromTasksArray = (tasks: Task[]): Map<string, Task> =>
|
||||
new Map(tasks.map((t) => [t.id, t]));
|
||||
|
||||
const emptyPlannerState: PlannerState = {
|
||||
days: {},
|
||||
addPlannedTasksDialogLastShown: undefined,
|
||||
};
|
||||
|
||||
const defaultScheduleConfig = {
|
||||
isWorkStartEndEnabled: false,
|
||||
workStart: '09:00',
|
||||
workEnd: '17:00',
|
||||
isLunchBreakEnabled: false,
|
||||
lunchBreakStart: '12:00',
|
||||
lunchBreakEnd: '13:00',
|
||||
};
|
||||
|
||||
// A daily cfg that still projects for today (startDate in the past,
|
||||
// lastTaskCreationDay not yet advanced to today) and has no startTime, so it
|
||||
// contributes its defaultEstimate via noStartTimeRepeatProjections.
|
||||
const dailyRepeatCfg = (id: string, defaultEstimate: number): TaskRepeatCfg => ({
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id,
|
||||
repeatCycle: 'DAILY',
|
||||
startDate: '2020-01-01',
|
||||
lastTaskCreationDay: '2020-01-01',
|
||||
startTime: undefined,
|
||||
defaultEstimate,
|
||||
});
|
||||
|
||||
it('counts a done recurring instance once (0 remaining), not also as a projection', () => {
|
||||
const cfg = dailyRepeatCfg('R1', ONE_HOUR);
|
||||
const task = createMockTask({
|
||||
id: 't1',
|
||||
repeatCfgId: 'R1',
|
||||
isDone: true,
|
||||
timeEstimate: ONE_HOUR,
|
||||
timeSpent: 0,
|
||||
});
|
||||
|
||||
// Pass t1 as a today-list task id (the reporter sees this in the Today column).
|
||||
const selector = fromSelectors.selectPlannerDays(
|
||||
[today],
|
||||
[cfg],
|
||||
['t1'],
|
||||
[],
|
||||
[],
|
||||
today,
|
||||
);
|
||||
const result = selector.projector(
|
||||
createTasksMapFromTasksArray([task]),
|
||||
emptyPlannerState,
|
||||
defaultScheduleConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result[0].timeEstimate).toBe(0);
|
||||
expect(result[0].itemsTotal).toBe(1);
|
||||
});
|
||||
|
||||
it('counts an undone recurring instance once, not doubled', () => {
|
||||
const cfg = dailyRepeatCfg('R1', ONE_HOUR);
|
||||
const task = createMockTask({
|
||||
id: 't1',
|
||||
repeatCfgId: 'R1',
|
||||
isDone: false,
|
||||
timeEstimate: ONE_HOUR,
|
||||
timeSpent: 0,
|
||||
});
|
||||
|
||||
const selector = fromSelectors.selectPlannerDays(
|
||||
[today],
|
||||
[cfg],
|
||||
['t1'],
|
||||
[],
|
||||
[],
|
||||
today,
|
||||
);
|
||||
const result = selector.projector(
|
||||
createTasksMapFromTasksArray([task]),
|
||||
emptyPlannerState,
|
||||
defaultScheduleConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result[0].timeEstimate).toBe(ONE_HOUR);
|
||||
expect(result[0].itemsTotal).toBe(1);
|
||||
});
|
||||
|
||||
it('dedupes a timed (startTime) projection too, keeping it out of scheduledIItems', () => {
|
||||
// A cfg WITH a startTime flows through repeatProjectionsForDay (the timed
|
||||
// list) rather than noStartTimeRepeatProjections, so this exercises the other
|
||||
// filtered array and the scheduledIItems dedup.
|
||||
const cfg: TaskRepeatCfg = {
|
||||
...dailyRepeatCfg('R1', ONE_HOUR),
|
||||
startTime: '09:00',
|
||||
};
|
||||
const task = createMockTask({
|
||||
id: 't1',
|
||||
repeatCfgId: 'R1',
|
||||
isDone: true,
|
||||
timeEstimate: ONE_HOUR,
|
||||
timeSpent: 0,
|
||||
});
|
||||
|
||||
const selector = fromSelectors.selectPlannerDays(
|
||||
[today],
|
||||
[cfg],
|
||||
['t1'],
|
||||
[],
|
||||
[],
|
||||
today,
|
||||
);
|
||||
const result = selector.projector(
|
||||
createTasksMapFromTasksArray([task]),
|
||||
emptyPlannerState,
|
||||
defaultScheduleConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result[0].timeEstimate).toBe(0);
|
||||
expect(result[0].itemsTotal).toBe(1);
|
||||
expect(result[0].scheduledIItems.length).toBe(0);
|
||||
});
|
||||
|
||||
it('still projects a recurring cfg that has no task instance in the day', () => {
|
||||
const cfg = dailyRepeatCfg('R2', ONE_HOUR);
|
||||
|
||||
const selector = fromSelectors.selectPlannerDays([today], [cfg], [], [], [], today);
|
||||
const result = selector.projector(
|
||||
createTasksMapFromTasksArray([]),
|
||||
emptyPlannerState,
|
||||
defaultScheduleConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result[0].timeEstimate).toBe(ONE_HOUR);
|
||||
expect(result[0].itemsTotal).toBe(1);
|
||||
});
|
||||
});
|
||||
191
src/app/features/planner/store/planner.bug-8232.spec.ts
Normal file
191
src/app/features/planner/store/planner.bug-8232.spec.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import * as fromSelectors from './planner.selectors';
|
||||
import { PlannerState } from './planner.reducer';
|
||||
import { Task, TaskWithDueTime } from '../../tasks/task.model';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
|
||||
import {
|
||||
DEFAULT_TASK_REPEAT_CFG,
|
||||
TaskRepeatCfg,
|
||||
} from '../../task-repeat-cfg/task-repeat-cfg.model';
|
||||
|
||||
// Regression for #8232: the dedup added in #8220/#8229 keyed only off
|
||||
// `normalTasks`, but a recurring task with `startTime` is created with
|
||||
// `dueWithTime` and therefore lives in `allPlannedTasks` -> `scheduledTaskItems`,
|
||||
// never in `normalTasks` on the Today column. The projection was not dropped,
|
||||
// and `getScheduledTaskItems` ignored `isDone`, so a done timed recurring
|
||||
// instance contributed roughly 2x its estimate (full from scheduledTaskItems +
|
||||
// full from the projection). Today only ever counts the real (done-aware) task,
|
||||
// so the two views disagreed again — the original #8220 symptom for the timed
|
||||
// subset.
|
||||
describe('Planner Selectors - #8232 timed recurring done task double-count', () => {
|
||||
const today = getDbDateStr();
|
||||
const ONE_HOUR = 60 * 60 * 1000;
|
||||
|
||||
const createMockTask = (overrides: Partial<Task> & { id: string }): Task => {
|
||||
const { id, ...rest } = overrides;
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
created: Date.now(),
|
||||
isDone: false,
|
||||
subTaskIds: [],
|
||||
tagIds: [],
|
||||
projectId: 'project1',
|
||||
timeSpentOnDay: {},
|
||||
timeEstimate: 0,
|
||||
timeSpent: 0,
|
||||
attachments: [],
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
const createTasksMapFromTasksArray = (tasks: Task[]): Map<string, Task> =>
|
||||
new Map(tasks.map((t) => [t.id, t]));
|
||||
|
||||
const emptyPlannerState: PlannerState = {
|
||||
days: {},
|
||||
addPlannedTasksDialogLastShown: undefined,
|
||||
};
|
||||
|
||||
const defaultScheduleConfig = {
|
||||
isWorkStartEndEnabled: false,
|
||||
workStart: '09:00',
|
||||
workEnd: '17:00',
|
||||
isLunchBreakEnabled: false,
|
||||
lunchBreakStart: '12:00',
|
||||
lunchBreakEnd: '13:00',
|
||||
};
|
||||
|
||||
// A daily cfg with a fixed startTime — projects via `repeatProjectionsForDay`
|
||||
// (the timed list) and, when an instance exists, that instance has
|
||||
// `dueWithTime` set so it flows through `allPlannedTasks` /
|
||||
// `scheduledTaskItems`.
|
||||
const dailyTimedRepeatCfg = (
|
||||
id: string,
|
||||
defaultEstimate: number,
|
||||
startTime = '09:00',
|
||||
): TaskRepeatCfg => ({
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id,
|
||||
repeatCycle: 'DAILY',
|
||||
startDate: '2020-01-01',
|
||||
lastTaskCreationDay: '2020-01-01',
|
||||
startTime,
|
||||
defaultEstimate,
|
||||
});
|
||||
|
||||
const todayAt = (clock: string): number =>
|
||||
getDateTimeFromClockString(clock, dateStrToUtcDate(today).getTime());
|
||||
|
||||
it('done timed recurring instance contributes 0 remaining, not estimate x 2', () => {
|
||||
const cfg = dailyTimedRepeatCfg('R1', ONE_HOUR);
|
||||
const task = createMockTask({
|
||||
id: 't1',
|
||||
repeatCfgId: 'R1',
|
||||
isDone: true,
|
||||
timeEstimate: ONE_HOUR,
|
||||
timeSpent: 0,
|
||||
dueWithTime: todayAt('09:00'),
|
||||
}) as TaskWithDueTime;
|
||||
|
||||
// The reporter's setup: instance is in today's list AND in allPlannedTasks
|
||||
// (because dueWithTime is set on the recurring instance).
|
||||
const selector = fromSelectors.selectPlannerDays(
|
||||
[today],
|
||||
[cfg],
|
||||
['t1'],
|
||||
[],
|
||||
[task],
|
||||
today,
|
||||
);
|
||||
const result = selector.projector(
|
||||
createTasksMapFromTasksArray([task]),
|
||||
emptyPlannerState,
|
||||
defaultScheduleConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result[0].timeEstimate).toBe(0);
|
||||
// One real (done) instance, projection dropped.
|
||||
expect(result[0].itemsTotal).toBe(1);
|
||||
// Scheduled list still shows the instance (visible in the day), but as a
|
||||
// zero-length item — no double-booked projection alongside it.
|
||||
expect(result[0].scheduledIItems.length).toBe(1);
|
||||
});
|
||||
|
||||
it('undone timed recurring instance contributes one estimate, not two', () => {
|
||||
const cfg = dailyTimedRepeatCfg('R1', ONE_HOUR);
|
||||
const task = createMockTask({
|
||||
id: 't1',
|
||||
repeatCfgId: 'R1',
|
||||
isDone: false,
|
||||
timeEstimate: ONE_HOUR,
|
||||
timeSpent: 0,
|
||||
dueWithTime: todayAt('09:00'),
|
||||
}) as TaskWithDueTime;
|
||||
|
||||
const selector = fromSelectors.selectPlannerDays(
|
||||
[today],
|
||||
[cfg],
|
||||
['t1'],
|
||||
[],
|
||||
[task],
|
||||
today,
|
||||
);
|
||||
const result = selector.projector(
|
||||
createTasksMapFromTasksArray([task]),
|
||||
emptyPlannerState,
|
||||
defaultScheduleConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result[0].timeEstimate).toBe(ONE_HOUR);
|
||||
expect(result[0].itemsTotal).toBe(1);
|
||||
expect(result[0].scheduledIItems.length).toBe(1);
|
||||
});
|
||||
|
||||
it('done timed task with no recurring cfg also contributes 0 (getScheduledTaskItems is done-aware)', () => {
|
||||
// Independent of the dedup fix: a one-off done timed task used to contribute
|
||||
// its full estimate via scheduledTaskItems.
|
||||
const task = createMockTask({
|
||||
id: 't1',
|
||||
isDone: true,
|
||||
timeEstimate: ONE_HOUR,
|
||||
timeSpent: 0,
|
||||
dueWithTime: todayAt('10:00'),
|
||||
}) as TaskWithDueTime;
|
||||
|
||||
const selector = fromSelectors.selectPlannerDays(
|
||||
[today],
|
||||
[],
|
||||
['t1'],
|
||||
[],
|
||||
[task],
|
||||
today,
|
||||
);
|
||||
const result = selector.projector(
|
||||
createTasksMapFromTasksArray([task]),
|
||||
emptyPlannerState,
|
||||
defaultScheduleConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result[0].timeEstimate).toBe(0);
|
||||
});
|
||||
|
||||
it('still projects a timed recurring cfg that has no instance in the day', () => {
|
||||
const cfg = dailyTimedRepeatCfg('R2', ONE_HOUR);
|
||||
|
||||
const selector = fromSelectors.selectPlannerDays([today], [cfg], [], [], [], today);
|
||||
const result = selector.projector(
|
||||
createTasksMapFromTasksArray([]),
|
||||
emptyPlannerState,
|
||||
defaultScheduleConfig,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(result[0].timeEstimate).toBe(ONE_HOUR);
|
||||
expect(result[0].itemsTotal).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -186,14 +186,39 @@ const getPlannerDay = (
|
|||
// Filter out tasks with dueDay in future if it is Today's column
|
||||
.filter((t) => !isTodayI || !t.dueDay || t.dueDay <= todayStr);
|
||||
|
||||
const { repeatProjectionsForDay, noStartTimeRepeatProjections } =
|
||||
getAllRepeatableTasksForDay(taskRepeatCfgs, currentDayTimestamp);
|
||||
const {
|
||||
repeatProjectionsForDay: allRepeatProjectionsForDay,
|
||||
noStartTimeRepeatProjections: allNoStartTimeRepeatProjections,
|
||||
} = getAllRepeatableTasksForDay(taskRepeatCfgs, currentDayTimestamp);
|
||||
|
||||
const scheduledTaskItems = getScheduledTaskItems(
|
||||
allPlannedTasks,
|
||||
dayDate,
|
||||
startOfNextDayDiffMs,
|
||||
);
|
||||
|
||||
// A recurring task can already have a real instance in this day, either in
|
||||
// normalTasks (untimed instance, or any non-Today column) or in
|
||||
// scheduledTaskItems (timed `dueWithTime` instance on Today — excluded from
|
||||
// normalTasks because allPlannedTasks owns it). Drop the projection for any
|
||||
// config that already has an instance here so the same recurring task is not
|
||||
// counted twice — once as the real (done-aware) task and once as a full-
|
||||
// estimate projection. The "Today" view only ever counts the real task, so
|
||||
// without this the two views disagree on remaining time for done recurring
|
||||
// tasks. See #8220, #8232.
|
||||
const coveredRepeatCfgIds = new Set<string>();
|
||||
for (const t of normalTasks) {
|
||||
if (t.repeatCfgId) coveredRepeatCfgIds.add(t.repeatCfgId);
|
||||
}
|
||||
for (const si of scheduledTaskItems) {
|
||||
if (si.task.repeatCfgId) coveredRepeatCfgIds.add(si.task.repeatCfgId);
|
||||
}
|
||||
const repeatProjectionsForDay = allRepeatProjectionsForDay.filter(
|
||||
(rp) => !coveredRepeatCfgIds.has(rp.repeatCfg.id),
|
||||
);
|
||||
const noStartTimeRepeatProjections = allNoStartTimeRepeatProjections.filter(
|
||||
(rp) => !coveredRepeatCfgIds.has(rp.repeatCfg.id),
|
||||
);
|
||||
const { timedEvents, allDayEvents } = getIcalEventsForDay(
|
||||
calendarEvents,
|
||||
dayDate,
|
||||
|
|
@ -326,7 +351,11 @@ const getScheduledTaskItems = (
|
|||
)
|
||||
.map((task) => {
|
||||
const start = task.dueWithTime;
|
||||
const end = start + Math.max(task.timeEstimate - task.timeSpent, 0);
|
||||
// Mirror normalTasks: a done task contributes 0 remaining time. Without
|
||||
// this, a done timed task still adds its full estimate to the day total
|
||||
// (and a done timed recurring task double-counted with its projection
|
||||
// before the dedup above). See #8232.
|
||||
const end = start + (task.isDone ? 0 : getTimeLeftForTask(task));
|
||||
return {
|
||||
id: task.id,
|
||||
type: ScheduleItemType.Task,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { of, Subscription, timer } from 'rxjs';
|
|||
import { SwipeDirective } from '../../ui/swipe-gesture/swipe.directive';
|
||||
import { CssString, StyleObject, StyleObjectToString } from '../../util/styles';
|
||||
import { LS } from '../../core/persistence/storage-keys.const';
|
||||
import { Log } from '../../core/log';
|
||||
import { readNumberLSBounded } from '../../util/ls-util';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
|
|
@ -558,7 +559,7 @@ export class RightPanelComponent implements AfterViewInit, OnDestroy {
|
|||
try {
|
||||
localStorage.setItem(LS.RIGHT_PANEL_WIDTH, this.currentWidth().toString());
|
||||
} catch (error) {
|
||||
console.warn('Failed to save right panel width to localStorage:', error);
|
||||
Log.warn('Failed to save right panel width to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -577,7 +578,7 @@ export class RightPanelComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
// Additional validation
|
||||
if (!Number.isFinite(width) || width < RIGHT_PANEL_CONFIG.MIN_WIDTH) {
|
||||
console.warn('Invalid right panel width detected, using default');
|
||||
Log.warn('Invalid right panel width detected, using default');
|
||||
this.currentWidth.set(RIGHT_PANEL_CONFIG.DEFAULT_WIDTH);
|
||||
return;
|
||||
}
|
||||
|
|
@ -586,7 +587,7 @@ export class RightPanelComponent implements AfterViewInit, OnDestroy {
|
|||
const validatedWidth = clampWidth(width, RIGHT_PANEL_CONFIG.MAX_WIDTH);
|
||||
this.currentWidth.set(validatedWidth);
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize right panel width:', error);
|
||||
Log.warn('Failed to initialize right panel width:', error);
|
||||
this.currentWidth.set(RIGHT_PANEL_CONFIG.DEFAULT_WIDTH);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@
|
|||
{{ T.F.CALENDARS.CONTEXT_MENU.OPEN_IN_CALENDAR | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (isCalendarEventFromPlugin()) {
|
||||
@if (canRescheduleCalendarEvent()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="rescheduleCalendarEvent()"
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ describe('ScheduleEventComponent – isReferenceCalendar', () => {
|
|||
useValue: {
|
||||
hasEventUrl: jasmine.createSpy('hasEventUrl').and.returnValue(false),
|
||||
isPluginEvent: jasmine.createSpy('isPluginEvent').and.returnValue(false),
|
||||
canMoveEvent: jasmine.createSpy('canMoveEvent').and.returnValue(false),
|
||||
createAsTask: jasmine.createSpy('createAsTask'),
|
||||
hideForever: jasmine.createSpy('hideForever'),
|
||||
},
|
||||
|
|
@ -96,6 +97,26 @@ describe('ScheduleEventComponent – isReferenceCalendar', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('canRescheduleCalendarEvent signal', () => {
|
||||
it('should return false when the calendar provider cannot update events', () => {
|
||||
fixture.componentRef.setInput('event', makeCalendarScheduleEvent(false));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.canRescheduleCalendarEvent()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when the calendar provider can update events', () => {
|
||||
const calActions = TestBed.inject(
|
||||
CalendarEventActionsService,
|
||||
) as jasmine.SpyObj<CalendarEventActionsService>;
|
||||
calActions.canMoveEvent.and.returnValue(true);
|
||||
fixture.componentRef.setInput('event', makeCalendarScheduleEvent(false));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.canRescheduleCalendarEvent()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clickHandler – reference calendar with empty menu', () => {
|
||||
it('should not throw when clicking a reference calendar event with no menu items', async () => {
|
||||
fixture.componentRef.setInput('event', makeCalendarScheduleEvent(true));
|
||||
|
|
|
|||
|
|
@ -410,6 +410,12 @@ export class ScheduleEventComponent implements AfterViewInit, OnDestroy {
|
|||
return !!(evt.data as ScheduleFromCalendarEvent).isReferenceCalendar;
|
||||
});
|
||||
|
||||
readonly canRescheduleCalendarEvent = computed(() => {
|
||||
const evt = this.se();
|
||||
if (evt.type !== SVEType.CalendarEvent) return false;
|
||||
return this._calEventActions.canMoveEvent(evt.data as ScheduleFromCalendarEvent);
|
||||
});
|
||||
|
||||
async openCalendarEventLink(): Promise<void> {
|
||||
const evt = this.se();
|
||||
if (evt.type !== SVEType.CalendarEvent) return;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { GlobalConfigState } from '../../config/global-config.model';
|
|||
import { ScheduleEvent } from '../schedule.model';
|
||||
import { FH, SVEType, T_ID_PREFIX } from '../schedule.const';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { CalendarEventActionsService } from '../../calendar-integration/calendar-event-actions.service';
|
||||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
|
||||
|
|
@ -21,6 +22,7 @@ describe('ScheduleWeekDragService', () => {
|
|||
let service: ScheduleWeekDragService;
|
||||
let store: MockStore;
|
||||
let dispatchSpy: jasmine.Spy;
|
||||
let calendarEventActionsSpy: jasmine.SpyObj<CalendarEventActionsService>;
|
||||
|
||||
const createMockGlobalConfigService = (
|
||||
defaultTaskRemindOption: TaskReminderOptionId = TaskReminderOptionId.AtStart,
|
||||
|
|
@ -45,6 +47,13 @@ describe('ScheduleWeekDragService', () => {
|
|||
providers: [
|
||||
ScheduleWeekDragService,
|
||||
provideMockStore(),
|
||||
{
|
||||
provide: CalendarEventActionsService,
|
||||
useValue: jasmine.createSpyObj<CalendarEventActionsService>(
|
||||
'CalendarEventActionsService',
|
||||
['canMoveEvent', 'moveToStartTime'],
|
||||
),
|
||||
},
|
||||
{
|
||||
provide: GlobalConfigService,
|
||||
useValue: createMockGlobalConfigService(defaultTaskRemindOption),
|
||||
|
|
@ -54,6 +63,11 @@ describe('ScheduleWeekDragService', () => {
|
|||
|
||||
service = TestBed.inject(ScheduleWeekDragService);
|
||||
store = TestBed.inject(MockStore);
|
||||
calendarEventActionsSpy = TestBed.inject(
|
||||
CalendarEventActionsService,
|
||||
) as jasmine.SpyObj<CalendarEventActionsService>;
|
||||
calendarEventActionsSpy.canMoveEvent.and.returnValue(true);
|
||||
calendarEventActionsSpy.moveToStartTime.and.resolveTo(true);
|
||||
dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
|
||||
};
|
||||
|
||||
|
|
@ -127,6 +141,211 @@ describe('ScheduleWeekDragService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('calendar event drag release', () => {
|
||||
const createReleaseEvent = (
|
||||
sourceEvent: ScheduleEvent,
|
||||
sourceEl: HTMLElement,
|
||||
): CdkDragRelease<ScheduleEvent> =>
|
||||
({
|
||||
source: {
|
||||
data: sourceEvent,
|
||||
element: {
|
||||
nativeElement: sourceEl,
|
||||
},
|
||||
reset: jasmine.createSpy('reset'),
|
||||
},
|
||||
event: new MouseEvent('mouseup', { clientX: 125, clientY: 120 }),
|
||||
}) as unknown as CdkDragRelease<ScheduleEvent>;
|
||||
|
||||
const createCalendarEvent = (): ScheduleEvent =>
|
||||
({
|
||||
id: 'calendar-1::event-1',
|
||||
type: SVEType.CalendarEvent,
|
||||
style: '',
|
||||
startHours: 10,
|
||||
timeLeftInHours: 0.5,
|
||||
data: {
|
||||
id: 'calendar-1::event-1',
|
||||
calProviderId: 'provider-1',
|
||||
issueProviderKey: 'plugin:google-calendar-provider',
|
||||
title: 'Meeting',
|
||||
start: new Date('2026-03-20T10:00:00Z').getTime(),
|
||||
duration: THIRTY_MINUTES_MS,
|
||||
icon: 'event',
|
||||
},
|
||||
}) as ScheduleEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
setupTestBed();
|
||||
});
|
||||
|
||||
const setupCalendarGrid = (): void => {
|
||||
const columnEl = document.createElement('div');
|
||||
columnEl.classList.add('col');
|
||||
columnEl.setAttribute('data-day', '2026-03-20');
|
||||
spyOn(document, 'elementsFromPoint').and.returnValue([columnEl]);
|
||||
|
||||
service.setGridContainer(() => {
|
||||
const gridEl = document.createElement('div');
|
||||
spyOn(gridEl, 'getBoundingClientRect').and.returnValue({
|
||||
top: 0,
|
||||
bottom: 24 * FH,
|
||||
left: 0,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 24 * FH,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
return gridEl;
|
||||
});
|
||||
service.setDaysToShowAccessor(() => ['2026-03-20']);
|
||||
};
|
||||
|
||||
it('moves plugin calendar events via the calendar action service when dropped on a time column', () => {
|
||||
const sourceEl = document.createElement('schedule-event');
|
||||
setupCalendarGrid();
|
||||
service.handleDragStarted({
|
||||
source: {
|
||||
data: createCalendarEvent(),
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
} as unknown as any);
|
||||
service.handleDragMoved({
|
||||
source: {
|
||||
data: createCalendarEvent(),
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
pointerPosition: { x: 125, y: 120 },
|
||||
} as unknown as any);
|
||||
service.handleDragReleased(createReleaseEvent(createCalendarEvent(), sourceEl));
|
||||
|
||||
expect(calendarEventActionsSpy.moveToStartTime).toHaveBeenCalledTimes(1);
|
||||
const [calendarEvent, startMs] =
|
||||
calendarEventActionsSpy.moveToStartTime.calls.mostRecent().args;
|
||||
expect(calendarEvent.id).toBe('calendar-1::event-1');
|
||||
expect(startMs).toEqual(jasmine.any(Number));
|
||||
});
|
||||
|
||||
it('treats shift-drop on a calendar event as a normal timed move', () => {
|
||||
const sourceEl = document.createElement('schedule-event');
|
||||
setupCalendarGrid();
|
||||
|
||||
service.setShiftMode(true);
|
||||
service.handleDragStarted({
|
||||
source: {
|
||||
data: createCalendarEvent(),
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
} as unknown as any);
|
||||
service.handleDragMoved({
|
||||
source: {
|
||||
data: createCalendarEvent(),
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
pointerPosition: { x: 125, y: 120 },
|
||||
} as unknown as any);
|
||||
service.handleDragReleased(createReleaseEvent(createCalendarEvent(), sourceEl));
|
||||
|
||||
expect(calendarEventActionsSpy.moveToStartTime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets the dragged calendar event after the provider write resolves', async () => {
|
||||
const sourceEl = document.createElement('schedule-event');
|
||||
setupCalendarGrid();
|
||||
const releaseEvent = createReleaseEvent(createCalendarEvent(), sourceEl);
|
||||
|
||||
service.handleDragStarted({
|
||||
source: {
|
||||
data: createCalendarEvent(),
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
} as unknown as any);
|
||||
service.handleDragMoved({
|
||||
source: {
|
||||
data: createCalendarEvent(),
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
pointerPosition: { x: 125, y: 120 },
|
||||
} as unknown as any);
|
||||
service.handleDragReleased(releaseEvent);
|
||||
|
||||
expect(releaseEvent.source.reset).not.toHaveBeenCalled();
|
||||
expect(sourceEl.style.pointerEvents).toBe('none');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(releaseEvent.source.reset).toHaveBeenCalled();
|
||||
expect(sourceEl.style.transform).toBe('translate3d(0px, 0px, 0px)');
|
||||
expect(sourceEl.style.pointerEvents).toBe('');
|
||||
});
|
||||
|
||||
it('suppresses the unschedule preview when dragging a calendar event outside the grid', () => {
|
||||
const sourceEl = document.createElement('schedule-event');
|
||||
setupCalendarGrid();
|
||||
|
||||
service.handleDragStarted({
|
||||
source: {
|
||||
data: createCalendarEvent(),
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
} as unknown as any);
|
||||
service.handleDragMoved({
|
||||
source: {
|
||||
data: createCalendarEvent(),
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
pointerPosition: { x: 125, y: 5000 },
|
||||
} as unknown as any);
|
||||
|
||||
expect(service.dragPreviewContext()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not move calendar events when the provider is read-only', () => {
|
||||
calendarEventActionsSpy.canMoveEvent.and.returnValue(false);
|
||||
const sourceEl = document.createElement('schedule-event');
|
||||
const columnEl = document.createElement('div');
|
||||
columnEl.classList.add('col');
|
||||
columnEl.setAttribute('data-day', '2026-03-20');
|
||||
spyOn(document, 'elementsFromPoint').and.returnValue([columnEl]);
|
||||
|
||||
service.setGridContainer(() => {
|
||||
const gridEl = document.createElement('div');
|
||||
spyOn(gridEl, 'getBoundingClientRect').and.returnValue({
|
||||
top: 0,
|
||||
bottom: 24 * FH,
|
||||
left: 0,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 24 * FH,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
return gridEl;
|
||||
});
|
||||
service.setDaysToShowAccessor(() => ['2026-03-20']);
|
||||
|
||||
const event = createCalendarEvent();
|
||||
service.handleDragStarted({
|
||||
source: {
|
||||
data: event,
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
} as unknown as any);
|
||||
service.handleDragMoved({
|
||||
source: {
|
||||
data: event,
|
||||
element: { nativeElement: sourceEl },
|
||||
},
|
||||
pointerPosition: { x: 125, y: 120 },
|
||||
} as unknown as any);
|
||||
service.handleDragReleased(createReleaseEvent(event, sourceEl));
|
||||
|
||||
expect(calendarEventActionsSpy.moveToStartTime).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('drag preview sizing', () => {
|
||||
beforeEach(() => {
|
||||
setupTestBed();
|
||||
|
|
|
|||
|
|
@ -16,16 +16,29 @@ import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const'
|
|||
import { calculateTimeFromYPosition } from '../schedule-utils';
|
||||
import { isTouchActive } from '../../../util/input-intent';
|
||||
import type { DragPreviewContext } from './schedule-week-drag.types';
|
||||
import type { ScheduleEvent } from '../schedule.model';
|
||||
import {
|
||||
isScheduleCalendarEvent,
|
||||
type ScheduleEvent,
|
||||
type ScheduleFromCalendarEvent,
|
||||
} from '../schedule.model';
|
||||
import { selectTodayTaskIds } from '../../work-context/store/work-context.selectors';
|
||||
import { first } from 'rxjs/operators';
|
||||
import { getTimeLeftForTask } from '../../../util/get-time-left-for-task';
|
||||
import { CalendarEventActionsService } from '../../calendar-integration/calendar-event-actions.service';
|
||||
|
||||
interface PointerPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface CalendarDropParams {
|
||||
calEv: ScheduleFromCalendarEvent;
|
||||
columnTarget: HTMLElement | null;
|
||||
dropPoint: PointerPosition | null;
|
||||
nativeEl: HTMLElement;
|
||||
ev: CdkDragRelease<ScheduleEvent>;
|
||||
}
|
||||
|
||||
const DRAG_CLONE_CLASS = 'drag-clone';
|
||||
const DRAG_OVER_CLASS = 'drag-over';
|
||||
const HOUR_IN_MS = 60 * 60 * 1000;
|
||||
|
|
@ -35,6 +48,7 @@ export class ScheduleWeekDragService {
|
|||
// Central drag state handler so the component can remain mostly declarative.
|
||||
private readonly _store = inject(Store);
|
||||
private readonly _globalConfigService = inject(GlobalConfigService);
|
||||
private readonly _calendarEventActions = inject(CalendarEventActionsService);
|
||||
|
||||
private readonly _isShiftMode = signal(false);
|
||||
readonly isShiftMode: Signal<boolean> = this._isShiftMode.asReadonly();
|
||||
|
|
@ -176,7 +190,10 @@ export class ScheduleWeekDragService {
|
|||
const targetDay = this._getDayUnderPointer(pointer.x, pointer.y);
|
||||
const isWithinGrid = this._isWithinGrid(pointer, gridRect);
|
||||
|
||||
if (this.isShiftMode()) {
|
||||
const draggedCalendarEvent = this._pluckMovableCalendarEvent(
|
||||
this._currentDragEvent(),
|
||||
);
|
||||
if (this.isShiftMode() && !draggedCalendarEvent) {
|
||||
this._handleShiftDragMove(targetEl, pointer, gridRect, targetDay, isWithinGrid);
|
||||
} else {
|
||||
this._handleTimeDragMove(pointer, gridRect, targetDay, isWithinGrid);
|
||||
|
|
@ -211,6 +228,7 @@ export class ScheduleWeekDragService {
|
|||
|
||||
const sourceEvent = ev.source.data;
|
||||
const task = this._pluckTaskFromEvent(sourceEvent);
|
||||
const calEv = this._pluckMovableCalendarEvent(sourceEvent);
|
||||
const sourceTaskId = nativeEl.id.replace(T_ID_PREFIX, '');
|
||||
const targetTaskId = scheduleEventTarget
|
||||
? scheduleEventTarget.id.replace(T_ID_PREFIX, '')
|
||||
|
|
@ -221,12 +239,25 @@ export class ScheduleWeekDragService {
|
|||
targetTaskId.length > 0 &&
|
||||
sourceTaskId !== targetTaskId;
|
||||
|
||||
// Plugin-backed calendar events can be moved directly via their provider.
|
||||
if (!task && calEv) {
|
||||
this._handleCalendarEventDrop({
|
||||
calEv,
|
||||
columnTarget,
|
||||
dropPoint,
|
||||
nativeEl,
|
||||
ev: ev as CdkDragRelease<ScheduleEvent>,
|
||||
});
|
||||
}
|
||||
|
||||
// Guard: nothing to do without a task
|
||||
if (!task) {
|
||||
this._currentDragEvent.set(null);
|
||||
this._resetDragRelatedVars();
|
||||
nativeEl.style.transform = 'translate3d(0, 0, 0)';
|
||||
ev.source.reset();
|
||||
if (!calEv) {
|
||||
nativeEl.style.transform = 'translate3d(0, 0, 0)';
|
||||
ev.source.reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -266,6 +297,41 @@ export class ScheduleWeekDragService {
|
|||
ev.source.reset();
|
||||
}
|
||||
|
||||
private _handleCalendarEventDrop({
|
||||
calEv,
|
||||
columnTarget,
|
||||
dropPoint,
|
||||
nativeEl,
|
||||
ev,
|
||||
}: CalendarDropParams): void {
|
||||
nativeEl.style.pointerEvents = 'none';
|
||||
|
||||
const resetDrag = (): void => {
|
||||
nativeEl.style.transform = 'translate3d(0, 0, 0)';
|
||||
nativeEl.style.pointerEvents = '';
|
||||
ev.source.reset();
|
||||
};
|
||||
|
||||
if (!columnTarget) {
|
||||
resetDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDay =
|
||||
columnTarget.getAttribute('data-day') ||
|
||||
(dropPoint ? this._getDayUnderPointer(dropPoint.x, dropPoint.y) : null);
|
||||
const scheduleTime =
|
||||
this._lastCalculatedTimestamp ??
|
||||
(dropPoint && targetDay ? this._calculateTimeFromDrop(dropPoint, targetDay) : null);
|
||||
|
||||
if (scheduleTime == null) {
|
||||
resetDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
void this._calendarEventActions.moveToStartTime(calEv, scheduleTime).then(resetDrag);
|
||||
}
|
||||
|
||||
refreshPreviewForCurrentPointer(): void {
|
||||
if (!this._isDragging()) {
|
||||
return;
|
||||
|
|
@ -286,7 +352,10 @@ export class ScheduleWeekDragService {
|
|||
this._lastDropScheduleEvent;
|
||||
const isWithinGrid = this._isWithinGrid(pointer, gridRect);
|
||||
|
||||
if (this.isShiftMode()) {
|
||||
const draggedCalendarEvent = this._pluckMovableCalendarEvent(
|
||||
this._currentDragEvent(),
|
||||
);
|
||||
if (this.isShiftMode() && !draggedCalendarEvent) {
|
||||
if (targetEl) {
|
||||
this._handleShiftDragMove(targetEl, pointer, gridRect, targetDay, isWithinGrid);
|
||||
} else {
|
||||
|
|
@ -484,6 +553,11 @@ export class ScheduleWeekDragService {
|
|||
this._dragPreviewContext.set(null);
|
||||
}
|
||||
} else {
|
||||
if (this._pluckMovableCalendarEvent(this._currentDragEvent())) {
|
||||
this._dragPreviewContext.set(null);
|
||||
this._lastCalculatedTimestamp = null;
|
||||
return;
|
||||
}
|
||||
// Show different label based on whether task has scheduled time
|
||||
const task = this._pluckTaskFromEvent(this._currentDragEvent());
|
||||
const label = task?.dueWithTime ? '✖ Unschedule Time' : '✖ Unschedule from Today';
|
||||
|
|
@ -731,6 +805,18 @@ export class ScheduleWeekDragService {
|
|||
}
|
||||
}
|
||||
|
||||
private _pluckMovableCalendarEvent(
|
||||
event: ScheduleEvent | null,
|
||||
): ScheduleFromCalendarEvent | null {
|
||||
if (
|
||||
!isScheduleCalendarEvent(event) ||
|
||||
!this._calendarEventActions.canMoveEvent(event.data)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event.data;
|
||||
}
|
||||
|
||||
private _handleColumnDrop({
|
||||
task,
|
||||
columnTarget,
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@
|
|||
|
||||
<!-- Events -->
|
||||
@for (ev of safeEvents(); track trackEventKey(ev)) {
|
||||
@if (isDraggableSE(ev)) {
|
||||
@if (canDragEvent(ev)) {
|
||||
<schedule-event
|
||||
class="draggable"
|
||||
[cdkDragData]="ev"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { GlobalConfigService } from '../../config/global-config.service';
|
|||
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
|
||||
import { ScheduleEvent } from '../schedule.model';
|
||||
import { SVEType } from '../schedule.const';
|
||||
import { CalendarEventActionsService } from '../../calendar-integration/calendar-event-actions.service';
|
||||
|
||||
describe('ScheduleWeekComponent', () => {
|
||||
let fixture: ComponentFixture<ScheduleWeekComponent>;
|
||||
|
|
@ -32,6 +33,13 @@ describe('ScheduleWeekComponent', () => {
|
|||
cfg: signal(DEFAULT_GLOBAL_CONFIG),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CalendarEventActionsService,
|
||||
useValue: jasmine.createSpyObj<CalendarEventActionsService>(
|
||||
'CalendarEventActionsService',
|
||||
['canMoveEvent'],
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(ScheduleWeekComponent, {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { ScheduleEvent } from '../schedule.model';
|
||||
import { isScheduleCalendarEvent, ScheduleEvent } from '../schedule.model';
|
||||
import { CdkDragMove, CdkDragRelease, CdkDragStart } from '@angular/cdk/drag-drop';
|
||||
import { FH, SVEType } from '../schedule.const';
|
||||
import { isDraggableSE } from '../map-schedule-data/is-schedule-types-type';
|
||||
|
|
@ -34,6 +34,7 @@ import { calculatePlaceholderForGridMove } from './schedule-week-placeholder.uti
|
|||
import { formatScheduleDragPreviewLabel } from './format-schedule-drag-preview-label.util';
|
||||
import { truncate } from '../../../util/truncate';
|
||||
import { LS } from '../../../core/persistence/storage-keys.const';
|
||||
import { CalendarEventActionsService } from '../../calendar-integration/calendar-event-actions.service';
|
||||
|
||||
const D_HOURS = 24;
|
||||
const DEFAULT_ROW_HEIGHT_PX = 9;
|
||||
|
|
@ -80,6 +81,7 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
private readonly _service = inject(ScheduleWeekDragService);
|
||||
private _dateTimeFormatService = inject(DateTimeFormatService);
|
||||
private _translateService = inject(TranslateService);
|
||||
private _calendarEventActions = inject(CalendarEventActionsService);
|
||||
|
||||
isInPanel = input<boolean>(false);
|
||||
isHorizontalScrollMode = input<boolean>(false);
|
||||
|
|
@ -187,6 +189,16 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
return `${ev.id}_${ev.plannedForDay ?? ''}_${ev.startHours}`;
|
||||
}
|
||||
|
||||
canDragEvent(ev: ScheduleEvent): boolean {
|
||||
if (isScheduleCalendarEvent(ev)) {
|
||||
return this._calendarEventActions.canMoveEvent(ev.data);
|
||||
}
|
||||
if (isDraggableSE(ev)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
newTaskPlaceholder = signal<{
|
||||
style: string;
|
||||
time: string;
|
||||
|
|
|
|||
|
|
@ -79,16 +79,9 @@ export interface ScheduleFromCalendarEvent extends CalendarIntegrationEvent {
|
|||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleCustomEvent extends Omit<
|
||||
ScheduleFromCalendarEvent,
|
||||
'calProviderId'
|
||||
> {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface SVECalendarEvent extends SVEBase {
|
||||
type: SVEType.CalendarEvent;
|
||||
data: ScheduleCustomEvent;
|
||||
data: ScheduleFromCalendarEvent;
|
||||
}
|
||||
|
||||
export interface ScheduleWorkStartEndCfg {
|
||||
|
|
@ -138,6 +131,16 @@ export interface ScheduleCalendarMapEntry {
|
|||
items: ScheduleFromCalendarEvent[];
|
||||
}
|
||||
|
||||
export const isScheduleCalendarEvent = (
|
||||
event: ScheduleEvent | null,
|
||||
): event is ScheduleEvent & {
|
||||
type: SVEType.CalendarEvent;
|
||||
data: ScheduleFromCalendarEvent;
|
||||
} =>
|
||||
event?.type === SVEType.CalendarEvent &&
|
||||
!!event.data &&
|
||||
typeof (event.data as { issueProviderKey?: unknown }).issueProviderKey === 'string';
|
||||
|
||||
// -----------------
|
||||
// BlockedBlocks
|
||||
export enum BlockedBlockType {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ describe('ScheduleComponent', () => {
|
|||
[
|
||||
'hasEventUrl',
|
||||
'isPluginEvent',
|
||||
'canMoveEvent',
|
||||
'openEventLink',
|
||||
'reschedule',
|
||||
'createAsTask',
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@
|
|||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
padding: var(--s);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.habit-tracker-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
|
@ -23,6 +24,9 @@
|
|||
gap: 12px var(--s);
|
||||
margin-bottom: var(--s2);
|
||||
padding: 0 var(--s-half);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 400px;
|
||||
|
||||
@include mq(xs, max) {
|
||||
margin-bottom: 12px;
|
||||
|
|
@ -70,34 +74,46 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0; // Critical for flex children truncation
|
||||
}
|
||||
|
||||
.header-row,
|
||||
.habit-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) repeat(7, 48px);
|
||||
gap: var(--s-half);
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
grid-template-columns: minmax(0, 1fr) repeat(7, 40px);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
grid-template-columns: minmax(0, 1fr) repeat(7, 32px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@include mq(xs, max) {
|
||||
grid-template-columns: 40px repeat(7, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
margin-bottom: var(--s-half);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.header-spacer {
|
||||
flex: 0 0 120px;
|
||||
width: 120px;
|
||||
|
||||
@include mq(xs, max) {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.day-header {
|
||||
flex: 1 1 0px;
|
||||
min-width: 30px;
|
||||
max-width: 100px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
|
||||
@include mq(xs, max) {
|
||||
min-width: 32px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
|
|
@ -117,10 +133,7 @@
|
|||
}
|
||||
|
||||
.habit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--s) 0;
|
||||
|
||||
border-bottom: 1px solid var(--c-dark-10);
|
||||
|
||||
@include darkTheme() {
|
||||
|
|
@ -132,12 +145,24 @@
|
|||
}
|
||||
|
||||
&.cdk-drag-preview {
|
||||
display: flex; // Maintain flex for floating preview
|
||||
align-items: center;
|
||||
gap: var(--s-half);
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--card-border-radius);
|
||||
box-shadow:
|
||||
0 5px 5px -3px var(--c-dark-20),
|
||||
0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.habit-title {
|
||||
flex: 1 1 0px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
flex: 0 0 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&.cdk-drag-placeholder {
|
||||
|
|
@ -150,8 +175,6 @@
|
|||
}
|
||||
|
||||
.habit-title {
|
||||
flex: 0 0 120px;
|
||||
width: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
|
@ -261,6 +284,7 @@
|
|||
.disabled-section {
|
||||
margin-top: var(--s);
|
||||
padding: 0 var(--s-half);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.disabled-section-header {
|
||||
|
|
@ -306,17 +330,10 @@
|
|||
}
|
||||
|
||||
.day-cell {
|
||||
flex: 1 1 0px;
|
||||
min-width: 30px;
|
||||
max-width: 100px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
@include mq(xs, max) {
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: default;
|
||||
opacity: 0.3;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { DateService } from 'src/app/core/date/date.service';
|
|||
import { GlobalTrackingIntervalService } from '../../core/global-tracking-interval/global-tracking-interval.service';
|
||||
import { ImexViewService } from '../../imex/imex-meta/imex-view.service';
|
||||
import { BatchedTimeSyncAccumulator } from '../../core/util/batched-time-sync-accumulator';
|
||||
import { Log } from '../../core/log';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -223,7 +224,7 @@ export class SimpleCounterService implements OnDestroy {
|
|||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[SimpleCounterService] Error syncing stopwatch value:', error);
|
||||
Log.err('[SimpleCounterService] Error syncing stopwatch value:', error);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
let mockDateTimeFormatService: jasmine.SpyObj<DateTimeFormatService>;
|
||||
let mockDateService: jasmine.SpyObj<DateService>;
|
||||
|
||||
// DateService is mocked to this fixed day; assertions about "today" must
|
||||
// derive from these consts, never from the real clock (see #8017 CI breakage).
|
||||
const MOCK_TODAY = new Date(2026, 5, 9, 0, 0, 0, 0);
|
||||
const MOCK_TODAY_STR = '2026-06-09';
|
||||
|
||||
const mockRepeatCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'repeat-cfg-123',
|
||||
|
|
@ -83,8 +88,8 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
'todayStr',
|
||||
'getLogicalTodayDate',
|
||||
]);
|
||||
mockDateService.todayStr.and.returnValue('2026-06-09');
|
||||
mockDateService.getLogicalTodayDate.and.returnValue(new Date(2026, 5, 9, 0, 0, 0, 0));
|
||||
mockDateService.todayStr.and.returnValue(MOCK_TODAY_STR);
|
||||
mockDateService.getLogicalTodayDate.and.returnValue(new Date(MOCK_TODAY));
|
||||
|
||||
// Set up the return value for getTaskRepeatCfgById$ before creating the component
|
||||
if (getTaskRepeatCfgById$ReturnValue) {
|
||||
|
|
@ -303,8 +308,9 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
(c) => c.key === T.F.TASK_REPEAT.F.Q_MONTHLY_CURRENT_DATE,
|
||||
);
|
||||
|
||||
const today = new Date();
|
||||
const todayDayStr = today.toLocaleDateString('en-US', { day: 'numeric' });
|
||||
// "today" comes from the mocked DateService.getLogicalTodayDate (2026-06-09),
|
||||
// not the wall clock — asserting against new Date() breaks on any other day
|
||||
const todayDayStr = MOCK_TODAY.toLocaleDateString('en-US', { day: 'numeric' });
|
||||
|
||||
expect(monthlyCall).toBeDefined();
|
||||
expect(monthlyCall!.params.dateDayStr).toBe(todayDayStr);
|
||||
|
|
@ -379,11 +385,8 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
});
|
||||
|
||||
it('should preserve WEEKLY_CURRENT_WEEKDAY when startDate weekday differs from today', async () => {
|
||||
// Pick a date whose weekday definitely differs from today
|
||||
const today = new Date();
|
||||
const differentDay = new Date(today);
|
||||
differentDay.setDate(today.getDate() + 3); // 3 days from now is a different weekday
|
||||
const dateStr = differentDay.toISOString().slice(0, 10);
|
||||
// Pick a date whose weekday definitely differs from the mocked today
|
||||
const dateStr = '2026-06-12'; // Friday; MOCK_TODAY is a Tuesday
|
||||
|
||||
const cfgWeekly: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
|
|
@ -453,7 +456,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
const component = fixture.componentInstance;
|
||||
component.openScheduleDialog();
|
||||
|
||||
const expectedToday = new Date(2026, 5, 9, 0, 0, 0, 0);
|
||||
const expectedToday = new Date(MOCK_TODAY);
|
||||
expect(mockMatDialog.open).toHaveBeenCalledWith(
|
||||
jasmine.any(Function),
|
||||
jasmine.objectContaining({
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { MatTooltip } from '@angular/material/tooltip';
|
|||
import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service';
|
||||
import { getNextRepeatOccurrence } from '../store/get-next-repeat-occurrence.util';
|
||||
import { formatMonthDay } from '../../../util/format-month-day.util';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
@Component({
|
||||
selector: 'repeat-cfg-preview',
|
||||
|
|
@ -50,7 +51,7 @@ export class RepeatCfgPreviewComponent {
|
|||
const nextLabel = this._translateService.instant(T.SCHEDULE.NEXT);
|
||||
return `${nextLabel} ${formatted}`;
|
||||
} catch (e) {
|
||||
console.warn('Failed to compute next repeat occurrence', e);
|
||||
Log.warn('Failed to compute next repeat occurrence', e);
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { isValidSplitTime } from '../../../util/is-valid-split-time';
|
|||
import { normalizeClockStr } from '../../../util/normalize-clock-str';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
import { getNextWeekDayOffset } from '../../../util/get-next-week-day-offset';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
|
|
@ -235,11 +236,7 @@ export class DialogDeadlineComponent implements AfterViewInit {
|
|||
d.setDate(d.getDate() + 1);
|
||||
return d;
|
||||
case 'nextWeek': {
|
||||
const dayOffset =
|
||||
(this._dateAdapter.getFirstDayOfWeek() -
|
||||
this._dateAdapter.getDayOfWeek(d) +
|
||||
7) %
|
||||
7 || 7;
|
||||
const dayOffset = getNextWeekDayOffset(this._dateAdapter, d);
|
||||
d.setDate(d.getDate() + dayOffset);
|
||||
return d;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,19 @@
|
|||
{{ task.dueWithTime | localeDate: 'short' }}
|
||||
}
|
||||
</div>
|
||||
@if (shouldShowDeadlineScheduleHint(task)) {
|
||||
<div class="schedule-hint">
|
||||
<mat-icon>event</mat-icon>
|
||||
<span>
|
||||
{{ T.F.TASK.D_REMINDER_VIEW.SCHEDULED_FOR | translate }}
|
||||
@if (task.dueWithTime) {
|
||||
{{ task.dueWithTime | localeDate: 'short' }}
|
||||
} @else {
|
||||
{{ task.dueDay | localDateStr: '' }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="title">{{ task.title }}</div>
|
||||
<tag-list
|
||||
[isShowProjectTagAlways]="true"
|
||||
|
|
|
|||
|
|
@ -55,6 +55,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
.schedule-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-half);
|
||||
margin-left: var(--s);
|
||||
padding-bottom: var(--s-half);
|
||||
color: var(--text-color-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
|
||||
mat-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: var(--s);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ import { ReminderService } from '../../reminder/reminder.service';
|
|||
import { TODAY_TAG } from '../../tag/tag.const';
|
||||
import { DEFAULT_TASK, Task, TaskWithReminderData } from '../task.model';
|
||||
import { TaskService } from '../task.service';
|
||||
import { DialogViewTaskRemindersComponent } from './dialog-view-task-reminders.component';
|
||||
import {
|
||||
DialogViewTaskRemindersComponent,
|
||||
shouldShowDeadlineScheduleHint,
|
||||
} from './dialog-view-task-reminders.component';
|
||||
|
||||
/**
|
||||
* Tests for the tasks$ filter logic in DialogViewTaskRemindersComponent.
|
||||
|
|
@ -194,6 +197,69 @@ describe('DialogViewTaskRemindersComponent planForTomorrow deadline clearing', (
|
|||
});
|
||||
});
|
||||
|
||||
describe('shouldShowDeadlineScheduleHint', () => {
|
||||
const today = '2026-04-25';
|
||||
const now = new Date(2026, 3, 25, 12, 0).getTime();
|
||||
|
||||
const buildTask = (overrides: Partial<TaskWithReminderData>): TaskWithReminderData =>
|
||||
({
|
||||
...DEFAULT_TASK,
|
||||
id: 'task-1',
|
||||
title: 'Test task',
|
||||
...overrides,
|
||||
}) as TaskWithReminderData;
|
||||
|
||||
it('shows for deadline reminders scheduled with a future time', () => {
|
||||
const task = buildTask({
|
||||
isDeadlineReminder: true,
|
||||
dueWithTime: now + 60_000,
|
||||
});
|
||||
|
||||
expect(shouldShowDeadlineScheduleHint(task, today, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('shows for deadline reminders scheduled on a future day', () => {
|
||||
const task = buildTask({
|
||||
isDeadlineReminder: true,
|
||||
dueDay: '2026-04-26',
|
||||
});
|
||||
|
||||
expect(shouldShowDeadlineScheduleHint(task, today, now)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show for normal task reminders', () => {
|
||||
const task = buildTask({
|
||||
isDeadlineReminder: false,
|
||||
dueWithTime: now + 60_000,
|
||||
});
|
||||
|
||||
expect(shouldShowDeadlineScheduleHint(task, today, now)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not show for today-only or past schedules', () => {
|
||||
expect(
|
||||
shouldShowDeadlineScheduleHint(
|
||||
buildTask({
|
||||
isDeadlineReminder: true,
|
||||
dueDay: today,
|
||||
}),
|
||||
today,
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowDeadlineScheduleHint(
|
||||
buildTask({
|
||||
isDeadlineReminder: true,
|
||||
dueWithTime: now - 60_000,
|
||||
}),
|
||||
today,
|
||||
now,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for the dismissed reminder tracking logic in DialogViewTaskRemindersComponent.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -44,6 +44,20 @@ import { MatTooltip } from '@angular/material/tooltip';
|
|||
|
||||
const MINUTES_TO_MILLISECONDS = 1000 * 60;
|
||||
|
||||
export const shouldShowDeadlineScheduleHint = (
|
||||
task: Pick<TaskWithReminderData, 'isDeadlineReminder' | 'dueWithTime' | 'dueDay'>,
|
||||
todayStr: string,
|
||||
now: number = Date.now(),
|
||||
): boolean => {
|
||||
if (!task.isDeadlineReminder) {
|
||||
return false;
|
||||
}
|
||||
if (typeof task.dueWithTime === 'number') {
|
||||
return task.dueWithTime > now;
|
||||
}
|
||||
return typeof task.dueDay === 'string' && task.dueDay > todayStr;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-view-task-reminder',
|
||||
templateUrl: './dialog-view-task-reminders.component.html',
|
||||
|
|
@ -339,6 +353,10 @@ export class DialogViewTaskRemindersComponent implements OnDestroy {
|
|||
return tasks.some((t) => !t.isDeadlineReminder);
|
||||
}
|
||||
|
||||
shouldShowDeadlineScheduleHint(task: TaskWithReminderData): boolean {
|
||||
return shouldShowDeadlineScheduleHint(task, this._dateService.todayStr());
|
||||
}
|
||||
|
||||
trackById(i: number, task: Task): string {
|
||||
return task.id;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { ipcAddTaskFromAppUri$ } from '../../../core/ipc-events';
|
|||
import { TaskService } from '../task.service';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { LOCAL_ACTIONS } from '../../../util/local-actions.token';
|
||||
import { TaskLog } from '../../../core/log';
|
||||
|
||||
// TODO send message to electron when current task changes here
|
||||
|
||||
|
|
@ -205,7 +206,10 @@ export class TaskElectronEffects {
|
|||
tap((data) => {
|
||||
// Double-check data validity as defensive programming
|
||||
if (!data || !data.title || typeof data.title !== 'string') {
|
||||
console.error('handleAddTaskFromProtocol$ received invalid data:', data);
|
||||
TaskLog.err('handleAddTaskFromProtocol$ received invalid data', {
|
||||
hasData: !!data,
|
||||
titleType: typeof data?.title,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._taskService.add(data.title);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { androidInterface } from '../../android/android-interface';
|
||||
import { generateNotificationId } from '../../android/android-notification-id.util';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { TaskLog } from '../../../core/log';
|
||||
|
||||
@Injectable()
|
||||
export class TaskReminderEffects {
|
||||
|
|
@ -97,7 +98,7 @@ export class TaskReminderEffects {
|
|||
const notificationId = generateNotificationId(task.id);
|
||||
androidInterface.cancelNativeReminder?.(notificationId);
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native reminder:', e);
|
||||
TaskLog.err('Failed to cancel native reminder:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,7 +116,7 @@ export class TaskReminderEffects {
|
|||
const notificationId = generateNotificationId(task.id + '_deadline');
|
||||
androidInterface.cancelNativeReminder?.(notificationId);
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native deadline reminder:', e);
|
||||
TaskLog.err('Failed to cancel native deadline reminder:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +219,7 @@ export class TaskReminderEffects {
|
|||
androidInterface.cancelNativeReminder?.(deadlineNotificationId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native reminder:', e);
|
||||
TaskLog.err('Failed to cancel native reminder:', e);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
|
@ -243,7 +244,7 @@ export class TaskReminderEffects {
|
|||
const notificationId = generateNotificationId(id);
|
||||
androidInterface.cancelNativeReminder?.(notificationId);
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native reminder:', e);
|
||||
TaskLog.err('Failed to cancel native reminder:', e);
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
|
@ -267,7 +268,7 @@ export class TaskReminderEffects {
|
|||
generateNotificationId(id + '_deadline'),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native reminder:', e);
|
||||
TaskLog.err('Failed to cancel native reminder:', e);
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
|
@ -290,7 +291,7 @@ export class TaskReminderEffects {
|
|||
generateNotificationId(id + '_deadline'),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native reminder:', e);
|
||||
TaskLog.err('Failed to cancel native reminder:', e);
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
|
@ -313,7 +314,7 @@ export class TaskReminderEffects {
|
|||
generateNotificationId(task.id + '_deadline'),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native reminder:', e);
|
||||
TaskLog.err('Failed to cancel native reminder:', e);
|
||||
}
|
||||
// Also cancel for subtasks
|
||||
task.subTaskIds?.forEach((subId) => {
|
||||
|
|
@ -323,7 +324,7 @@ export class TaskReminderEffects {
|
|||
generateNotificationId(subId + '_deadline'),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native reminder:', e);
|
||||
TaskLog.err('Failed to cancel native reminder:', e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { TaskAttachment } from '../task-attachment.model';
|
|||
import { T } from '../../../../t.const';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ClipboardImageService } from '../../../../core/clipboard-image/clipboard-image.service';
|
||||
import { Log } from '../../../../core/log';
|
||||
|
||||
describe('TaskAttachmentListComponent', () => {
|
||||
let component: TaskAttachmentListComponent;
|
||||
|
|
@ -152,12 +153,12 @@ describe('TaskAttachmentListComponent', () => {
|
|||
.and.returnValue(Promise.reject(new Error('Permission denied')));
|
||||
setNavigatorClipboard({ writeText: writeTextSpy });
|
||||
document.execCommand = jasmine.createSpy('execCommand').and.returnValue(true);
|
||||
spyOn(console, 'warn');
|
||||
spyOn(Log, 'warn');
|
||||
|
||||
await component.copy(attachment);
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('https://example.com/test');
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalled();
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
expect(snackService.open).toHaveBeenCalledWith(T.GLOBAL_SNACK.COPY_TO_CLIPPBOARD);
|
||||
});
|
||||
|
|
@ -180,12 +181,12 @@ describe('TaskAttachmentListComponent', () => {
|
|||
document.execCommand = jasmine
|
||||
.createSpy('execCommand')
|
||||
.and.throwError('Command not supported');
|
||||
spyOn(console, 'error');
|
||||
spyOn(Log, 'err');
|
||||
|
||||
await component.copy(attachment);
|
||||
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
expect(Log.err).toHaveBeenCalled();
|
||||
expect(snackService.open).toHaveBeenCalledWith({
|
||||
msg: 'Failed to copy to clipboard. Please copy manually.',
|
||||
type: 'ERROR',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { EnlargeImgDirective } from '../../../../ui/enlarge-img/enlarge-img.dire
|
|||
import { MatAnchor, MatButton } from '@angular/material/button';
|
||||
import { ClipboardImageService } from '../../../../core/clipboard-image/clipboard-image.service';
|
||||
import { isPathSafeToOpen } from '../../../../../../electron/shared-with-frontend/is-external-url-allowed';
|
||||
import { Log } from '../../../../core/log';
|
||||
|
||||
interface ResolvedAttachment extends TaskAttachment {
|
||||
resolvedPath?: string;
|
||||
|
|
@ -141,7 +142,7 @@ export class TaskAttachmentListComponent {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resolving clipboard image:', error);
|
||||
Log.err('Error resolving clipboard image:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -192,7 +193,7 @@ export class TaskAttachmentListComponent {
|
|||
this._copyWithFallback(attachment.path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Clipboard write failed, trying fallback method:', error);
|
||||
Log.warn('Clipboard write failed, trying fallback method:', error);
|
||||
// Try fallback method if modern API fails
|
||||
this._copyWithFallback(attachment.path);
|
||||
}
|
||||
|
|
@ -220,7 +221,7 @@ export class TaskAttachmentListComponent {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fallback copy failed:', error);
|
||||
Log.err('Fallback copy failed:', error);
|
||||
this._snackService.open({
|
||||
msg: 'Failed to copy to clipboard. Please copy manually.',
|
||||
type: 'ERROR',
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@
|
|||
<mat-icon>arrow_upward</mat-icon>
|
||||
{{ T.F.TASK.CMP.MOVE_TO_REGULAR | translate }}
|
||||
</span>
|
||||
<span class="key-i">{{ kb.moveToTodaysTasks }}</span>
|
||||
<span class="key-i">{{ kb.taskScheduleToday }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import { getDbDateStr } from '../../../../util/get-db-date-str';
|
|||
import { PlannerActions } from '../../../planner/store/planner.actions';
|
||||
import { addSubTask } from '../../../tasks/store/task.actions';
|
||||
import { combineDateAndTime } from '../../../../util/combine-date-and-time';
|
||||
import { getNextWeekDayOffset } from '../../../../util/get-next-week-day-offset';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { ICAL_TYPE } from '../../../issue/issue.const';
|
||||
import { IssueIconPipe } from '../../../issue/issue-icon/issue-icon.pipe';
|
||||
|
|
@ -723,11 +724,7 @@ export class TaskContextMenuInnerComponent implements AfterViewInit, OnDestroy {
|
|||
break;
|
||||
case 3:
|
||||
const nextFirstDayOfWeek = tDate;
|
||||
const dayOffset =
|
||||
(this._dateAdapter.getFirstDayOfWeek() -
|
||||
this._dateAdapter.getDayOfWeek(nextFirstDayOfWeek) +
|
||||
7) %
|
||||
7 || 7;
|
||||
const dayOffset = getNextWeekDayOffset(this._dateAdapter, nextFirstDayOfWeek);
|
||||
nextFirstDayOfWeek.setDate(nextFirstDayOfWeek.getDate() + dayOffset);
|
||||
this._schedule(nextFirstDayOfWeek);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ describe('TaskShortcutService', () => {
|
|||
taskEditTags: 'G',
|
||||
taskOpenContextMenu: null,
|
||||
moveToBacklog: 'B',
|
||||
moveToTodaysTasks: 'F',
|
||||
taskScheduleToday: 'F',
|
||||
selectPreviousTask: 'K',
|
||||
selectNextTask: 'J',
|
||||
collapseSubTasks: 'H',
|
||||
|
|
|
|||
|
|
@ -169,6 +169,21 @@ export class TaskShortcutService {
|
|||
ev.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (checkKeyCombo(ev, keys.taskScheduleTomorrow)) {
|
||||
this._handleTaskShortcut(focusedTaskId, 'scheduleTaskTomorrow');
|
||||
ev.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (checkKeyCombo(ev, keys.taskScheduleNextWeek)) {
|
||||
this._handleTaskShortcut(focusedTaskId, 'scheduleTaskNextWeek');
|
||||
ev.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (checkKeyCombo(ev, keys.taskScheduleNextMonth)) {
|
||||
this._handleTaskShortcut(focusedTaskId, 'scheduleTaskNextMonth');
|
||||
ev.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (checkKeyCombo(ev, keys.taskScheduleDeadline)) {
|
||||
this._handleTaskShortcut(focusedTaskId, 'openDeadlineDialog');
|
||||
ev.preventDefault();
|
||||
|
|
@ -229,7 +244,7 @@ export class TaskShortcutService {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (checkKeyCombo(ev, keys.moveToTodaysTasks)) {
|
||||
if (checkKeyCombo(ev, keys.taskScheduleToday)) {
|
||||
this._handleTaskShortcut(focusedTaskId, 'moveToTodayWithFocus');
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
<button
|
||||
(click)="parent.addToMyDay()"
|
||||
title="{{ T.F.TASK.CMP.ADD_TO_MY_DAY | translate }} {{
|
||||
kb.moveToTodaysTasks ? '[' + kb.moveToTodaysTasks + ']' : ''
|
||||
kb.taskScheduleToday ? '[' + kb.taskScheduleToday + ']' : ''
|
||||
}}"
|
||||
class="ico-btn"
|
||||
color=""
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { TestBed } from '@angular/core/testing';
|
|||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of } from 'rxjs';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
|
||||
import { LayoutService } from '../../../core-ui/layout/layout.service';
|
||||
|
|
@ -15,6 +17,10 @@ import { DEFAULT_TASK, HideSubTasksMode, TaskWithSubTasks } from '../task.model'
|
|||
import { TaskService } from '../task.service';
|
||||
import { WorkContextService } from '../../work-context/work-context.service';
|
||||
import { TaskComponent } from './task.component';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { LocaleDatePipe } from '../../../ui/pipes/locale-date.pipe';
|
||||
import { PlannerService } from '../../planner/planner.service';
|
||||
|
||||
describe('TaskComponent shortcut handling', () => {
|
||||
let fixture: import('@angular/core/testing').ComponentFixture<TaskComponent>;
|
||||
|
|
@ -65,6 +71,7 @@ describe('TaskComponent shortcut handling', () => {
|
|||
'pauseCurrent',
|
||||
'getByIdWithSubTaskData$',
|
||||
'focusTaskById',
|
||||
'scheduleTask',
|
||||
],
|
||||
{
|
||||
currentTaskId: signal<string | null>(null),
|
||||
|
|
@ -101,7 +108,7 @@ describe('TaskComponent shortcut handling', () => {
|
|||
{
|
||||
provide: GlobalConfigService,
|
||||
useValue: jasmine.createSpyObj('GlobalConfigService', ['cfg'], {
|
||||
cfg: () => ({ keyboard: {}, tasks: {} }),
|
||||
cfg: () => ({ keyboard: {}, tasks: {}, reminder: {} }),
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
|
@ -112,6 +119,22 @@ describe('TaskComponent shortcut handling', () => {
|
|||
]),
|
||||
},
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{
|
||||
provide: SnackService,
|
||||
useValue: jasmine.createSpyObj('SnackService', ['open']),
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: jasmine.createSpyObj('TranslateService', ['instant']),
|
||||
},
|
||||
{
|
||||
provide: LocaleDatePipe,
|
||||
useValue: jasmine.createSpyObj('LocaleDatePipe', ['transform']),
|
||||
},
|
||||
{
|
||||
provide: PlannerService,
|
||||
useValue: jasmine.createSpyObj('PlannerService', ['getSnackExtraStr']),
|
||||
},
|
||||
{
|
||||
provide: ProjectService,
|
||||
useValue: jasmine.createSpyObj('ProjectService', [
|
||||
|
|
@ -130,9 +153,13 @@ describe('TaskComponent shortcut handling', () => {
|
|||
},
|
||||
{
|
||||
provide: DateService,
|
||||
useValue: jasmine.createSpyObj('DateService', ['isToday'], {
|
||||
isToday: () => false,
|
||||
}),
|
||||
useValue: jasmine.createSpyObj(
|
||||
'DateService',
|
||||
['isToday', 'getLogicalTodayDate'],
|
||||
{
|
||||
isToday: () => false,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
provide: GlobalTrackingIntervalService,
|
||||
|
|
@ -152,6 +179,13 @@ describe('TaskComponent shortcut handling', () => {
|
|||
isTodayList: signal(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DateAdapter,
|
||||
useValue: jasmine.createSpyObj('DateAdapter', [
|
||||
'getFirstDayOfWeek',
|
||||
'getDayOfWeek',
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(TaskComponent, {
|
||||
|
|
@ -337,4 +371,116 @@ describe('TaskComponent shortcut handling', () => {
|
|||
|
||||
expect(taskServiceSpy.addSubTaskTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Scheduling shortcuts', () => {
|
||||
let dateService: jasmine.SpyObj<DateService>;
|
||||
let dateAdapter: jasmine.SpyObj<DateAdapter<unknown>>;
|
||||
let plannerService: jasmine.SpyObj<PlannerService>;
|
||||
|
||||
beforeEach(() => {
|
||||
dateService = TestBed.inject(DateService) as jasmine.SpyObj<DateService>;
|
||||
dateAdapter = TestBed.inject(DateAdapter) as jasmine.SpyObj<DateAdapter<unknown>>;
|
||||
plannerService = TestBed.inject(PlannerService) as jasmine.SpyObj<PlannerService>;
|
||||
// Mock "logical today" to 2026-06-01 (a Monday)
|
||||
dateService.getLogicalTodayDate.and.returnValue(new Date('2026-06-01T12:00:00'));
|
||||
dateAdapter.getDayOfWeek.and.callFake((d: any) => (d as Date).getDay());
|
||||
dateAdapter.getFirstDayOfWeek.and.returnValue(1); // Monday
|
||||
plannerService.getSnackExtraStr.and.returnValue(Promise.resolve(''));
|
||||
});
|
||||
|
||||
it('schedules for tomorrow', () => {
|
||||
component.scheduleTaskTomorrow();
|
||||
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-06-02',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules for next week (next Monday)', () => {
|
||||
component.scheduleTaskNextWeek();
|
||||
|
||||
// Next week from Monday June 1st should be June 8th
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-06-08',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules for next week (from Sunday, next Monday)', () => {
|
||||
dateService.getLogicalTodayDate.and.returnValue(new Date('2026-06-07T12:00:00')); // Sunday
|
||||
|
||||
component.scheduleTaskNextWeek();
|
||||
|
||||
// Next week from Sunday June 7th (first day Monday) should be June 8th
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-06-08',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules for next week (from Sunday, next Monday) - US locale (Sunday first)', () => {
|
||||
dateAdapter.getFirstDayOfWeek.and.returnValue(0); // Sunday
|
||||
dateService.getLogicalTodayDate.and.returnValue(new Date('2026-06-07T12:00:00')); // Sunday
|
||||
|
||||
component.scheduleTaskNextWeek();
|
||||
|
||||
// Next week from Sunday June 7th (first day Sunday) should be June 14th
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-06-14',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules for next month (first of next month)', () => {
|
||||
component.scheduleTaskNextMonth();
|
||||
|
||||
expect(storeSpy.dispatch).toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: component.task() as any,
|
||||
day: '2026-07-01',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves time and reminder when scheduling a timed task for tomorrow', async () => {
|
||||
const timedTask = {
|
||||
...component.task(),
|
||||
dueWithTime: new Date('2026-06-01T10:00:00').getTime(),
|
||||
};
|
||||
fixture.componentRef.setInput('task', timedTask);
|
||||
|
||||
await component.scheduleTaskTomorrow();
|
||||
|
||||
// Should call taskService.scheduleTask instead of dispatching planTaskForDay
|
||||
// June 2nd at 10:00:00
|
||||
expect(taskServiceSpy.scheduleTask).toHaveBeenCalledWith(
|
||||
timedTask as any,
|
||||
new Date('2026-06-02T10:00:00').getTime(),
|
||||
jasmine.any(String),
|
||||
false,
|
||||
);
|
||||
expect(TestBed.inject(SnackService).open).toHaveBeenCalled();
|
||||
expect(storeSpy.dispatch).not.toHaveBeenCalledWith(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: timedTask as any,
|
||||
day: '2026-06-02',
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,13 +57,19 @@ import { TaskRepeatCfgService } from '../../task-repeat-cfg/task-repeat-cfg.serv
|
|||
import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component';
|
||||
import { DialogFullscreenMarkdownComponent } from '../../../ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component';
|
||||
import { Update } from '@ngrx/entity';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
|
||||
import { combineDateAndTime } from '../../../util/combine-date-and-time';
|
||||
import { getNextWeekDayOffset } from '../../../util/get-next-week-day-offset';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { isTouchActive } from '../../../util/input-intent';
|
||||
import { IS_HYBRID_DEVICE } from '../../../util/is-mouse-primary';
|
||||
import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants';
|
||||
import { KeyboardConfig } from '../../config/keyboard-config.model';
|
||||
import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component';
|
||||
import { PlannerActions } from '../../planner/store/planner.actions';
|
||||
import { PlannerService } from '../../planner/planner.service';
|
||||
import { DialogDeadlineComponent } from '../dialog-deadline/dialog-deadline.component';
|
||||
import { isDeadlineOverdue as isDeadlineOverdueFn } from '../util/is-deadline-overdue';
|
||||
import { isDeadlineApproaching as isDeadlineApproachingFn } from '../util/is-deadline-approaching';
|
||||
|
|
@ -79,7 +85,8 @@ import { TaskListComponent } from '../task-list/task-list.component';
|
|||
import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe';
|
||||
import { ShortPlannedAtPipe } from '../../../ui/pipes/short-planned-at.pipe';
|
||||
import { LocalDateStrPipe } from '../../../ui/pipes/local-date-str.pipe';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LocaleDatePipe } from '../../../ui/pipes/locale-date.pipe';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { SubTaskTotalTimeSpentPipe } from '../pipes/sub-task-total-time-spent.pipe';
|
||||
import { TagListComponent } from '../../tag/tag-list/tag-list.component';
|
||||
import { TagToggleMenuListComponent } from '../../tag/tag-toggle-menu-list/tag-toggle-menu-list.component';
|
||||
|
|
@ -93,8 +100,10 @@ import { LayoutService } from '../../../core-ui/layout/layout.service';
|
|||
import { TaskFocusService } from '../task-focus.service';
|
||||
import { selectTimeConflictTaskIds } from '../store/task.selectors';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { millisecondsDiffToRemindOption } from '../util/remind-option-to-milliseconds';
|
||||
import { MenuTreeService } from '../../menu-tree/menu-tree.service';
|
||||
import { SelectOptionRowComponent } from '../../../ui/select-option-row/select-option-row.component';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
|
||||
@Component({
|
||||
selector: 'task',
|
||||
|
|
@ -148,12 +157,17 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
|
|||
private readonly _configService = inject(GlobalConfigService);
|
||||
private readonly _attachmentService = inject(TaskAttachmentService);
|
||||
private readonly _elementRef = inject(ElementRef);
|
||||
private readonly _dateAdapter = inject(DateAdapter);
|
||||
private readonly _store = inject(Store);
|
||||
private readonly _projectService = inject(ProjectService);
|
||||
private readonly _taskFocusService = inject(TaskFocusService);
|
||||
private readonly _dateService = inject(DateService);
|
||||
private readonly _destroyRef = inject(DestroyRef);
|
||||
private readonly _menuTreeService = inject(MenuTreeService);
|
||||
private readonly _snackService = inject(SnackService);
|
||||
private readonly _translateService = inject(TranslateService);
|
||||
private readonly _datePipe = inject(LocaleDatePipe);
|
||||
private readonly _plannerService = inject(PlannerService);
|
||||
|
||||
readonly workContextService = inject(WorkContextService);
|
||||
readonly layoutService = inject(LayoutService);
|
||||
|
|
@ -506,6 +520,60 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
|
|||
});
|
||||
}
|
||||
|
||||
async scheduleTaskTomorrow(): Promise<void> {
|
||||
const tDate = this._dateService.getLogicalTodayDate();
|
||||
tDate.setDate(tDate.getDate() + 1);
|
||||
await this._scheduleForDay(tDate);
|
||||
}
|
||||
|
||||
async scheduleTaskNextWeek(): Promise<void> {
|
||||
const tDate = this._dateService.getLogicalTodayDate();
|
||||
const dayOffset = getNextWeekDayOffset(this._dateAdapter, tDate);
|
||||
tDate.setDate(tDate.getDate() + dayOffset);
|
||||
await this._scheduleForDay(tDate);
|
||||
}
|
||||
|
||||
async scheduleTaskNextMonth(): Promise<void> {
|
||||
const tDate = this._dateService.getLogicalTodayDate();
|
||||
tDate.setDate(1);
|
||||
tDate.setMonth(tDate.getMonth() + 1);
|
||||
await this._scheduleForDay(tDate);
|
||||
}
|
||||
|
||||
private async _scheduleForDay(dayDate: Date): Promise<void> {
|
||||
const day = getDbDateStr(dayDate);
|
||||
const task = this.task();
|
||||
if (task.dueWithTime) {
|
||||
const newDate = combineDateAndTime(dayDate, new Date(task.dueWithTime));
|
||||
const remindCfg = task.reminderId
|
||||
? millisecondsDiffToRemindOption(task.dueWithTime, task.remindAt)
|
||||
: (this._configService.cfg()?.reminder.defaultTaskRemindOption ??
|
||||
DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!);
|
||||
|
||||
this._taskService.scheduleTask(task, newDate.getTime(), remindCfg, false);
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: T.F.PLANNER.S.TASK_PLANNED_FOR,
|
||||
ico: 'today',
|
||||
translateParams: {
|
||||
date: this._dateService.isToday(newDate)
|
||||
? this._translateService.instant(T.G.TODAY_TAG_TITLE)
|
||||
: (this._datePipe.transform(day, 'shortDate') as string),
|
||||
extra: await this._plannerService.getSnackExtraStr(day),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this._store.dispatch(
|
||||
PlannerActions.planTaskForDay({
|
||||
task: task as TaskCopy,
|
||||
day,
|
||||
isShowSnack: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.focusSelfOrNextIfNotPossible();
|
||||
}
|
||||
|
||||
async editTaskRepeatCfg(): Promise<void> {
|
||||
const { DialogEditTaskRepeatCfgComponent } =
|
||||
await import('../../task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component');
|
||||
|
|
|
|||
|
|
@ -30,6 +30,6 @@ export const playDoneSound = async (
|
|||
source.detune.value = pitchFactor;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error playing done sound:', e);
|
||||
TaskLog.err('Error playing done sound:', e);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
|||
import { MatDivider } from '@angular/material/divider';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { T } from '../../../t.const';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
@Component({
|
||||
selector: 'user-profile-button',
|
||||
|
|
@ -94,7 +95,7 @@ export class UserProfileButtonComponent {
|
|||
try {
|
||||
await this.profileService.switchProfile(profileId);
|
||||
} catch (error) {
|
||||
console.error('Failed to switch profile:', error);
|
||||
Log.err('Failed to switch profile:', error);
|
||||
} finally {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Store } from '@ngrx/store';
|
|||
import { selectTasksWithSubTasksByIds } from '../tasks/store/task.selectors';
|
||||
import { Task, TaskWithSubTasks } from '../tasks/task.model';
|
||||
import { first } from 'rxjs/operators';
|
||||
import { Log } from '../../core/log';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -167,7 +168,7 @@ export class WorkContextMarkdownService {
|
|||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('Clipboard write failed, trying fallback method:', err);
|
||||
Log.warn('Clipboard write failed, trying fallback method:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +190,7 @@ export class WorkContextMarkdownService {
|
|||
try {
|
||||
isSuccess = document.execCommand('copy');
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
Log.err('Fallback copy failed:', err);
|
||||
isSuccess = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import type { SuperSyncPrivateCfg } from '@sp/sync-providers/super-sync';
|
||||
import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
|
||||
|
||||
describe('SnapshotUploadService', () => {
|
||||
let service: SnapshotUploadService;
|
||||
|
|
@ -182,6 +183,31 @@ describe('SnapshotUploadService', () => {
|
|||
expect(result.existingCfg).toEqual({ encryptKey: 'test' } as any);
|
||||
});
|
||||
|
||||
it('should strip local-only sync settings from gathered snapshot state', async () => {
|
||||
const mockState = {
|
||||
globalConfig: {
|
||||
...DEFAULT_GLOBAL_CONFIG,
|
||||
sync: {
|
||||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(mockState as any);
|
||||
|
||||
const result = await service.gatherSnapshotData();
|
||||
const globalConfig = result.state.globalConfig as Record<string, unknown>;
|
||||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
|
||||
expect(sync['syncProvider']).toBeNull();
|
||||
expect(sync['syncInterval']).toBeUndefined();
|
||||
expect(sync['isManualSyncOnly']).toBeUndefined();
|
||||
expect(sync['isCompressionEnabled']).toBe(true);
|
||||
});
|
||||
|
||||
it('should regenerate client ID when getOrGenerateClientId is used', async () => {
|
||||
mockClientIdProvider.getOrGenerateClientId.and.resolveTo('B_regen');
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { VectorClock } from '../../core/util/vector-clock';
|
|||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { isCryptoSubtleAvailable } from '@sp/sync-core';
|
||||
import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors';
|
||||
import { stripLocalOnlySyncSettingsFromAppData } from '../../features/config/local-only-sync-settings.util';
|
||||
|
||||
/**
|
||||
* Data gathered for a snapshot upload operation.
|
||||
|
|
@ -118,7 +119,9 @@ export class SnapshotUploadService {
|
|||
// IMPORTANT: Must use async version to load real archives from IndexedDB
|
||||
// The sync getStateSnapshot() returns DEFAULT_ARCHIVE (empty) which causes data loss
|
||||
SyncLog.normal(`${prefix}Getting current state...`);
|
||||
const state = await this._stateSnapshotService.getStateSnapshotAsync();
|
||||
const state = stripLocalOnlySyncSettingsFromAppData(
|
||||
await this._stateSnapshotService.getStateSnapshotAsync(),
|
||||
) as AppStateSnapshot;
|
||||
const vectorClock = await this._vectorClockService.getCurrentVectorClock();
|
||||
const clientId = await this._clientIdProvider.getOrGenerateClientId();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SnackService } from '../../core/snack/snack.service';
|
|||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { RestorePoint } from '../../op-log/sync-providers/provider.interface';
|
||||
import { T } from '../../t.const';
|
||||
import { SyncLog } from '../../core/log';
|
||||
|
||||
describe('SuperSyncRestoreService', () => {
|
||||
let service: SuperSyncRestoreService;
|
||||
|
|
@ -31,9 +32,9 @@ describe('SuperSyncRestoreService', () => {
|
|||
|
||||
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
|
||||
|
||||
// Spy on console methods for retry logging tests
|
||||
spyOn(console, 'warn');
|
||||
spyOn(console, 'error');
|
||||
// Spy on logger methods for retry logging tests
|
||||
spyOn(SyncLog, 'warn');
|
||||
spyOn(SyncLog, 'err');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
|
|
@ -215,7 +216,7 @@ describe('SuperSyncRestoreService', () => {
|
|||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(mockProvider.getStateAtSeq).toHaveBeenCalledTimes(1);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect(SyncLog.warn).toHaveBeenCalledWith(
|
||||
jasmine.stringContaining('Restore failed due to network error, retrying (1/2)'),
|
||||
error,
|
||||
);
|
||||
|
|
@ -229,7 +230,7 @@ describe('SuperSyncRestoreService', () => {
|
|||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(mockProvider.getStateAtSeq).toHaveBeenCalledTimes(2);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect(SyncLog.warn).toHaveBeenCalledWith(
|
||||
jasmine.stringContaining('Restore failed due to network error, retrying (2/2)'),
|
||||
error,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { AppDataComplete } from '../../op-log/model/model-config';
|
|||
import { T } from '../../t.const';
|
||||
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
||||
import { BackupService } from '../../op-log/backup/backup.service';
|
||||
import { SyncLog } from '../../core/log';
|
||||
|
||||
/**
|
||||
* Service for restoring state from Super Sync server history.
|
||||
|
|
@ -81,7 +82,7 @@ export class SuperSyncRestoreService {
|
|||
|
||||
if (isNetworkError && retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
console.warn(
|
||||
SyncLog.warn(
|
||||
`Restore failed due to network error, retrying (${retryCount}/${MAX_RETRIES}):`,
|
||||
error,
|
||||
);
|
||||
|
|
@ -90,7 +91,7 @@ export class SuperSyncRestoreService {
|
|||
continue;
|
||||
}
|
||||
|
||||
console.error('Failed to restore from point:', error);
|
||||
SyncLog.err('Failed to restore from point:', error);
|
||||
// The server can't replay end-to-end-encrypted ops to reconstruct an
|
||||
// earlier state, so it rejects the restore with a reason mentioning
|
||||
// encryption (surfaced in the thrown error message). Show an actionable
|
||||
|
|
|
|||
|
|
@ -15,10 +15,17 @@ import { Operation } from '../core/operation.types';
|
|||
* - Effects don't see individual actions (they only see this bulk action type)
|
||||
* - No need for LOCAL_ACTIONS filtering - effects naturally don't trigger
|
||||
* - Single store update = better performance
|
||||
*
|
||||
* `localClientId` is the id of THIS device. The bulk meta-reducer uses it to
|
||||
* tell own-op replay (clientId === localClientId) apart from genuinely remote
|
||||
* ops (another client) so that per-device "local-only" settings are preserved
|
||||
* only when a remote client's op would otherwise overwrite them — never when
|
||||
* replaying the device's own ops during hydration. See `bulkOperationsMetaReducer`
|
||||
* and `withLocalOnlySyncSettings`.
|
||||
*/
|
||||
export const bulkApplyOperations = createAction(
|
||||
'[OperationLog] Bulk Apply Operations',
|
||||
props<{ operations: Operation[] }>(),
|
||||
props<{ operations: Operation[]; localClientId?: string }>(),
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { Action } from '@ngrx/store';
|
||||
import { bulkHydrationMetaReducer } from './bulk-hydration.meta-reducer';
|
||||
import { bulkApplyHydrationOperations } from './bulk-hydration.action';
|
||||
import {
|
||||
bulkApplyHydrationOperations,
|
||||
bulkApplyOperations,
|
||||
} from './bulk-hydration.action';
|
||||
import { ActionType, Operation, OpType } from '../core/operation.types';
|
||||
import { RootState } from '../../root-store/root-state';
|
||||
import { TASK_FEATURE_NAME } from '../../features/tasks/store/task.reducer';
|
||||
|
|
@ -302,6 +305,56 @@ describe('bulkHydrationMetaReducer', () => {
|
|||
expect(calledAction.meta?.isRemote).toBe(true);
|
||||
});
|
||||
|
||||
it('should tag isApplyingFromOtherClient when op.clientId differs from localClientId', () => {
|
||||
const reducer = bulkHydrationMetaReducer(mockReducer);
|
||||
const state = createMockState();
|
||||
const operation = createMockOperation({ clientId: 'otherClient' });
|
||||
const action = bulkApplyOperations({
|
||||
operations: [operation],
|
||||
localClientId: 'thisClient',
|
||||
});
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const calledAction = reducerCalls[0].action as {
|
||||
meta?: { isApplyingFromOtherClient?: boolean };
|
||||
};
|
||||
expect(calledAction.meta?.isApplyingFromOtherClient).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT tag isApplyingFromOtherClient for own-op replay (clientId === localClientId)', () => {
|
||||
const reducer = bulkHydrationMetaReducer(mockReducer);
|
||||
const state = createMockState();
|
||||
const operation = createMockOperation({ clientId: 'thisClient' });
|
||||
const action = bulkApplyOperations({
|
||||
operations: [operation],
|
||||
localClientId: 'thisClient',
|
||||
});
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const calledAction = reducerCalls[0].action as {
|
||||
meta?: { isApplyingFromOtherClient?: boolean };
|
||||
};
|
||||
expect(calledAction.meta?.isApplyingFromOtherClient).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT tag isApplyingFromOtherClient when localClientId is unknown', () => {
|
||||
const reducer = bulkHydrationMetaReducer(mockReducer);
|
||||
const state = createMockState();
|
||||
const operation = createMockOperation({ clientId: 'otherClient' });
|
||||
// No localClientId on the action → cannot tell own from foreign; default to
|
||||
// own-op semantics so we never clobber the device's own settings.
|
||||
const action = bulkApplyOperations({ operations: [operation] });
|
||||
|
||||
reducer(state, action);
|
||||
|
||||
const calledAction = reducerCalls[0].action as {
|
||||
meta?: { isApplyingFromOtherClient?: boolean };
|
||||
};
|
||||
expect(calledAction.meta?.isApplyingFromOtherClient).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle Create operations', () => {
|
||||
const reducer = bulkHydrationMetaReducer(mockReducer);
|
||||
const state = createMockState();
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ export const bulkOperationsMetaReducer = <T>(
|
|||
): ActionReducer<T> => {
|
||||
return (state: T | undefined, action: Action): T => {
|
||||
if (action.type === bulkApplyOperations.type) {
|
||||
const { operations } = action as ReturnType<typeof bulkApplyOperations>;
|
||||
const { operations, localClientId } = action as ReturnType<
|
||||
typeof bulkApplyOperations
|
||||
>;
|
||||
|
||||
// Pre-scan: collect entity IDs being archived or deleted in this batch.
|
||||
// LWW Update ops for these entities must be skipped to prevent
|
||||
|
|
@ -82,7 +84,29 @@ export const bulkOperationsMetaReducer = <T>(
|
|||
)
|
||||
: op;
|
||||
const opAction = convertOpToAction(opForApply);
|
||||
currentState = reducer(currentState, opAction);
|
||||
// Mark ops authored by a DIFFERENT client so reducers can preserve
|
||||
// per-device "local-only" settings against remote overwrites — while
|
||||
// replaying the device's OWN ops faithfully.
|
||||
//
|
||||
// When localClientId is unknown we leave the flag unset (own-op
|
||||
// semantics: apply faithfully, don't preserve). In practice this only
|
||||
// happens before the clientId cache is warm — i.e. a never-synced or
|
||||
// cold-booting device. A device that actually has foreign ops to apply
|
||||
// has already resolved its clientId (download/upload/vector-clock all
|
||||
// require it), so genuine remote applies always carry it and stay
|
||||
// protected. The unset fallback deliberately favours own-op fidelity:
|
||||
// the alternative (blanket-preserve, the old `isRemote` gate) is what
|
||||
// silently nulled the device's own syncProvider on replay. The
|
||||
// residual risk — a foreign op adopting another device's provider/
|
||||
// isEnabled/isEncryptionEnabled — needs a transient IndexedDB failure
|
||||
// on a cold boot and is user-recoverable, strictly narrower than the
|
||||
// own-settings data-loss this replaces.
|
||||
const isApplyingFromOtherClient =
|
||||
!!localClientId && op.clientId !== localClientId;
|
||||
const finalAction = isApplyingFromOtherClient
|
||||
? { ...opAction, meta: { ...opAction.meta, isApplyingFromOtherClient: true } }
|
||||
: opAction;
|
||||
currentState = reducer(currentState, finalAction);
|
||||
}
|
||||
return currentState;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { ArchiveOperationHandler } from './archive-operation-handler.service';
|
|||
import { HydrationStateService } from './hydration-state.service';
|
||||
import { remoteArchiveDataApplied } from '../../features/archive/store/archive.actions';
|
||||
import { bulkApplyOperations } from './bulk-hydration.action';
|
||||
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
|
||||
import { OperationLogEffects } from '../capture/operation-log.effects';
|
||||
|
||||
describe('OperationApplierService', () => {
|
||||
|
|
@ -19,6 +20,7 @@ describe('OperationApplierService', () => {
|
|||
let mockArchiveOperationHandler: jasmine.SpyObj<ArchiveOperationHandler>;
|
||||
let mockHydrationState: jasmine.SpyObj<HydrationStateService>;
|
||||
let mockOperationLogEffects: jasmine.SpyObj<OperationLogEffects>;
|
||||
let mockClientIdProvider: jasmine.SpyObj<ClientIdProvider>;
|
||||
|
||||
const createMockOperation = (
|
||||
id: string,
|
||||
|
|
@ -53,9 +55,15 @@ describe('OperationApplierService', () => {
|
|||
mockOperationLogEffects = jasmine.createSpyObj('OperationLogEffects', [
|
||||
'processDeferredActions',
|
||||
]);
|
||||
mockClientIdProvider = jasmine.createSpyObj('ClientIdProvider', [
|
||||
'loadClientId',
|
||||
'getOrGenerateClientId',
|
||||
'clearCache',
|
||||
]);
|
||||
|
||||
mockArchiveOperationHandler.handleOperation.and.returnValue(Promise.resolve());
|
||||
mockOperationLogEffects.processDeferredActions.and.returnValue(Promise.resolve());
|
||||
mockClientIdProvider.loadClientId.and.resolveTo('testClient');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
|
|
@ -64,6 +72,7 @@ describe('OperationApplierService', () => {
|
|||
{ provide: ArchiveOperationHandler, useValue: mockArchiveOperationHandler },
|
||||
{ provide: HydrationStateService, useValue: mockHydrationState },
|
||||
{ provide: OperationLogEffects, useValue: mockOperationLogEffects },
|
||||
{ provide: CLIENT_ID_PROVIDER, useValue: mockClientIdProvider },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -79,7 +88,7 @@ describe('OperationApplierService', () => {
|
|||
|
||||
expect(result.appliedOps).toEqual([op]);
|
||||
expect(mockStore.dispatch).toHaveBeenCalledOnceWith(
|
||||
bulkApplyOperations({ operations: [op] }),
|
||||
bulkApplyOperations({ operations: [op], localClientId: 'testClient' }),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -475,6 +484,9 @@ describe('OperationApplierService', () => {
|
|||
}) as typeof window.setTimeout);
|
||||
|
||||
const applyPromise = service.applyOperations([op]);
|
||||
// applyOperations awaits the (cached) clientId load before dispatching, so
|
||||
// flush that one microtask before asserting the synchronous dispatch.
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(archiveCalls).toEqual([]);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { HydrationStateService } from './hydration-state.service';
|
||||
import { remoteArchiveDataApplied } from '../../features/archive/store/archive.actions';
|
||||
import { bulkApplyOperations } from './bulk-hydration.action';
|
||||
import { CLIENT_ID_PROVIDER } from '../util/client-id.provider';
|
||||
import { OperationLogEffects } from '../capture/operation-log.effects';
|
||||
import { ApplyOperationsResult, ApplyOperationsOptions } from '../core/types/apply.types';
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ export class OperationApplierService implements OperationApplyPort<Operation> {
|
|||
private store: ActionDispatchPort<SyncActionLike> = inject(Store);
|
||||
private archiveOperationHandler = inject(ArchiveOperationHandler);
|
||||
private hydrationState = inject(HydrationStateService);
|
||||
private clientIdProvider = inject(CLIENT_ID_PROVIDER);
|
||||
// Use Injector to avoid circular dependency: OperationLogEffects depends on services
|
||||
// that may depend on this service indirectly through the Store.
|
||||
private injector = inject(Injector);
|
||||
|
|
@ -86,11 +88,28 @@ export class OperationApplierService implements OperationApplyPort<Operation> {
|
|||
);
|
||||
}
|
||||
|
||||
// Identify THIS device so the bulk meta-reducer can tell own-op replay apart
|
||||
// from genuinely remote ops (preserves per-device local-only sync settings
|
||||
// only against another client's ops, never while replaying our own).
|
||||
//
|
||||
// Use loadClientId (lenient — returns null, never throws). Do NOT switch to
|
||||
// getOrGenerateClientId to "fail safe": applyRemoteOperations appends the
|
||||
// incoming ops as PENDING before calling this applier and only marks them
|
||||
// applied/failed from the returned result, so a throw here would strand those
|
||||
// appended ops — a same-session retry then skips them as duplicates. That is
|
||||
// worse than the unprotected-foreign-op leak it would prevent. The leak is
|
||||
// unreachable on this path anyway: applying remote ops means the clientId was
|
||||
// already resolved (download/vector-clocks need it), so the cached read
|
||||
// returns it. A null only happens off this path (cold-boot hydration), where
|
||||
// the unset flag's own-op default is the safe direction. See meta-reducer.
|
||||
const localClientId = (await this.clientIdProvider.loadClientId()) ?? undefined;
|
||||
|
||||
const result = await replayOperationBatch({
|
||||
ops,
|
||||
applyOptions: { isLocalHydration },
|
||||
dispatcher: this.store,
|
||||
createBulkApplyAction: (operations) => bulkApplyOperations({ operations }),
|
||||
createBulkApplyAction: (operations) =>
|
||||
bulkApplyOperations({ operations, localClientId }),
|
||||
remoteApplyWindow: this.hydrationState,
|
||||
deferredLocalActions: {
|
||||
processDeferredActions: () =>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { ClientIdService } from '../../core/util/client-id.service';
|
|||
import { OperationCaptureService } from './operation-capture.service';
|
||||
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
|
||||
import { T } from '../../t.const';
|
||||
import { updateGlobalConfigSection } from '../../features/config/store/global-config.actions';
|
||||
|
||||
describe('OperationLogEffects', () => {
|
||||
let effects: OperationLogEffects;
|
||||
|
|
@ -327,6 +328,74 @@ describe('OperationLogEffects', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should persist sync updates that only change local schedule settings for local replay', (done) => {
|
||||
const action = updateGlobalConfigSection({
|
||||
sectionKey: 'sync',
|
||||
sectionCfg: {
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
},
|
||||
});
|
||||
actions$ = of(action);
|
||||
|
||||
effects.persistOperation$.subscribe({
|
||||
complete: () => {
|
||||
expect(mockOperationCaptureService.dequeue).toHaveBeenCalled();
|
||||
expect(mockOpLogStore.appendWithVectorClockUpdate).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
actionType: ActionType.GLOBAL_CONFIG_UPDATE_SECTION,
|
||||
payload: {
|
||||
actionPayload: {
|
||||
sectionKey: 'sync',
|
||||
sectionCfg: {
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
},
|
||||
},
|
||||
entityChanges: [],
|
||||
},
|
||||
}),
|
||||
'local',
|
||||
);
|
||||
expect(mockImmediateUploadService.trigger).toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep local schedule settings in persisted sync updates', (done) => {
|
||||
const action = updateGlobalConfigSection({
|
||||
sectionKey: 'sync',
|
||||
sectionCfg: {
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
});
|
||||
actions$ = of(action);
|
||||
|
||||
effects.persistOperation$.subscribe({
|
||||
complete: () => {
|
||||
const operation =
|
||||
mockOpLogStore.appendWithVectorClockUpdate.calls.mostRecent().args[0];
|
||||
|
||||
expect(operation.actionType).toBe(ActionType.GLOBAL_CONFIG_UPDATE_SECTION);
|
||||
expect(operation.payload).toEqual({
|
||||
actionPayload: {
|
||||
sectionKey: 'sync',
|
||||
sectionCfg: {
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
},
|
||||
entityChanges: [],
|
||||
});
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist planTasksForToday replay date fields in actionPayload', (done) => {
|
||||
const action = TaskSharedActions.planTasksForToday({
|
||||
taskIds: ['task-1'],
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ export interface PersistentActionMeta {
|
|||
entityIds?: string[]; // For batch operations
|
||||
opType: OpType;
|
||||
isRemote?: boolean; // TRUE if from Sync (prevents re-logging)
|
||||
// TRUE only when the op being applied was authored by a DIFFERENT client
|
||||
// (set during bulk apply when op.clientId !== this device's clientId).
|
||||
// Distinct from isRemote, which is also TRUE for replay of the device's OWN
|
||||
// ops during hydration. Reducers that preserve per-device "local-only"
|
||||
// settings (e.g. sync config) must key off THIS flag, not isRemote, so they
|
||||
// don't clobber the device's own settings while replaying its own ops.
|
||||
isApplyingFromOtherClient?: boolean;
|
||||
isBulk?: boolean; // TRUE for batch operations
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ 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';
|
||||
import { SyncProviderId } from '../sync-providers/provider.const';
|
||||
|
||||
describe('OperationLogHydratorService', () => {
|
||||
let service: OperationLogHydratorService;
|
||||
|
|
@ -396,7 +397,10 @@ describe('OperationLogHydratorService', () => {
|
|||
expect(mockStore.dispatch).toHaveBeenCalledTimes(2);
|
||||
// Tail ops are replayed via bulk dispatch for performance
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(
|
||||
bulkApplyHydrationOperations({ operations: tailOps.map((e) => e.op) }),
|
||||
bulkApplyHydrationOperations({
|
||||
operations: tailOps.map((e) => e.op),
|
||||
localClientId: 'test-client',
|
||||
}),
|
||||
);
|
||||
// Hydration state is managed around the dispatch
|
||||
expect(mockHydrationStateService.startApplyingRemoteOps).toHaveBeenCalled();
|
||||
|
|
@ -699,6 +703,38 @@ describe('OperationLogHydratorService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should preserve local sync settings when replaying SYNC_IMPORT without state cache', async () => {
|
||||
const syncImportPayload = {
|
||||
task: {},
|
||||
project: {},
|
||||
globalConfig: {
|
||||
sync: {
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
isEnabled: true,
|
||||
isEncryptionEnabled: true,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const syncImportOp = createMockOperation('sync-op', OpType.SyncImport, {
|
||||
payload: syncImportPayload,
|
||||
entityType: 'ALL',
|
||||
});
|
||||
|
||||
mockOpLogStore.loadStateCache.and.returnValue(Promise.resolve(null));
|
||||
mockOpLogStore.getOpsAfterSeq.and.returnValue(
|
||||
Promise.resolve([createMockEntry(1, syncImportOp)]),
|
||||
);
|
||||
|
||||
await service.hydrateStore();
|
||||
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(
|
||||
loadAllData({ appDataComplete: syncImportPayload as any }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge BACKUP_IMPORT clock BEFORE loadAllData (regression test)', async () => {
|
||||
// REGRESSION TEST: Same fix applies to BACKUP_IMPORT operations.
|
||||
// BACKUP_IMPORT is a full-state operation like SYNC_IMPORT and has the same
|
||||
|
|
@ -896,7 +932,10 @@ describe('OperationLogHydratorService', () => {
|
|||
expect(mockSchemaMigrationService.migrateOperations).toHaveBeenCalled();
|
||||
// Operations should be applied via bulk dispatch
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(
|
||||
bulkApplyHydrationOperations({ operations: migratedOps }),
|
||||
bulkApplyHydrationOperations({
|
||||
operations: migratedOps,
|
||||
localClientId: 'test-client',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1065,7 +1104,10 @@ describe('OperationLogHydratorService', () => {
|
|||
// Must replay the whole op-log from seq 0.
|
||||
expect(mockOpLogStore.getOpsAfterSeq).toHaveBeenCalledWith(0);
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(
|
||||
bulkApplyHydrationOperations({ operations: allOps.map((e) => e.op) }),
|
||||
bulkApplyHydrationOperations({
|
||||
operations: allOps.map((e) => e.op),
|
||||
localClientId: 'test-client',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1097,7 +1139,10 @@ describe('OperationLogHydratorService', () => {
|
|||
|
||||
// Replay all ops via bulk dispatch for performance
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(
|
||||
bulkApplyHydrationOperations({ operations: allOps.map((e) => e.op) }),
|
||||
bulkApplyHydrationOperations({
|
||||
operations: allOps.map((e) => e.op),
|
||||
localClientId: 'test-client',
|
||||
}),
|
||||
);
|
||||
// Hydration state is managed around the dispatch
|
||||
expect(mockHydrationStateService.startApplyingRemoteOps).toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -251,8 +251,17 @@ export class OperationLogHydratorService {
|
|||
// PERF: Use bulk dispatch to apply all operations in a single NgRx update.
|
||||
// This reduces 500 dispatches to 1, dramatically improving startup performance.
|
||||
// The bulkHydrationMetaReducer iterates through ops and applies each action.
|
||||
// Lenient (no throw) so a cold-boot IndexedDB hiccup can't block
|
||||
// startup. A null clientId leaves the bulk-apply flag unset, which
|
||||
// defaults to own-op semantics (apply faithfully) — the safe
|
||||
// direction for the common case (replaying THIS device's own ops).
|
||||
// See bulkOperationsMetaReducer.
|
||||
const localClientId =
|
||||
(await this.clientIdProvider.loadClientId()) ?? undefined;
|
||||
this.hydrationStateService.startApplyingRemoteOps();
|
||||
this.store.dispatch(bulkApplyOperations({ operations: opsToReplay }));
|
||||
this.store.dispatch(
|
||||
bulkApplyOperations({ operations: opsToReplay, localClientId }),
|
||||
);
|
||||
this.hydrationStateService.endApplyingRemoteOps();
|
||||
|
||||
// Merge replayed ops' clocks into local clock
|
||||
|
|
@ -328,8 +337,16 @@ export class OperationLogHydratorService {
|
|||
// PERF: Use bulk dispatch to apply all operations in a single NgRx update.
|
||||
// This reduces 500 dispatches to 1, dramatically improving startup performance.
|
||||
// The bulkHydrationMetaReducer iterates through ops and applies each action.
|
||||
// Lenient (no throw) so a cold-boot IndexedDB hiccup can't block
|
||||
// startup. A null clientId leaves the bulk-apply flag unset, which
|
||||
// defaults to own-op semantics (apply faithfully) — the safe direction
|
||||
// for the common case (replaying THIS device's own ops). See
|
||||
// bulkOperationsMetaReducer.
|
||||
const localClientId = (await this.clientIdProvider.loadClientId()) ?? undefined;
|
||||
this.hydrationStateService.startApplyingRemoteOps();
|
||||
this.store.dispatch(bulkApplyOperations({ operations: opsToReplay }));
|
||||
this.store.dispatch(
|
||||
bulkApplyOperations({ operations: opsToReplay, localClientId }),
|
||||
);
|
||||
this.hydrationStateService.endApplyingRemoteOps();
|
||||
|
||||
// Merge replayed ops' clocks into local clock
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { loadAllData } from '../../root-store/meta/load-all-data.action';
|
|||
import { ActionType, OpType } from '../core/operation.types';
|
||||
import { SyncProviderId } from '../sync-providers/provider.const';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
|
||||
import { LOCAL_ONLY_SYNC_KEYS } from '../../features/config/local-only-sync-settings.util';
|
||||
import { SnackService } from '../../core/snack/snack.service';
|
||||
|
||||
describe('SyncHydrationService', () => {
|
||||
|
|
@ -28,6 +29,8 @@ describe('SyncHydrationService', () => {
|
|||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
isEnabled: true,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -223,11 +226,16 @@ describe('SyncHydrationService', () => {
|
|||
expect(vectorClock['remoteClient']).toBe(100);
|
||||
});
|
||||
|
||||
it('should strip syncProvider from globalConfig.sync', async () => {
|
||||
it('should persist local-only settings in local SYNC_IMPORT payload for replay', async () => {
|
||||
const downloadedData = {
|
||||
task: {},
|
||||
globalConfig: {
|
||||
sync: { syncProvider: 'dropbox', someOther: 'setting' },
|
||||
sync: {
|
||||
syncProvider: 'dropbox',
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: true,
|
||||
someOther: 'setting',
|
||||
},
|
||||
otherSetting: 'value',
|
||||
},
|
||||
};
|
||||
|
|
@ -238,7 +246,13 @@ describe('SyncHydrationService', () => {
|
|||
const payload = appendCall.args[0].payload as Record<string, unknown>;
|
||||
const globalConfig = payload['globalConfig'] as Record<string, unknown>;
|
||||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
expect(sync['syncProvider']).toBeNull();
|
||||
expect(sync['isEnabled']).toBe(defaultLocalSyncConfig.isEnabled);
|
||||
expect(sync['isEncryptionEnabled']).toBe(
|
||||
defaultLocalSyncConfig.isEncryptionEnabled,
|
||||
);
|
||||
expect(sync['syncProvider']).toBe(defaultLocalSyncConfig.syncProvider);
|
||||
expect(sync['syncInterval']).toBe(defaultLocalSyncConfig.syncInterval);
|
||||
expect(sync['isManualSyncOnly']).toBe(defaultLocalSyncConfig.isManualSyncOnly);
|
||||
expect(sync['someOther']).toBe('setting');
|
||||
expect(globalConfig['otherSetting']).toBe('value');
|
||||
});
|
||||
|
|
@ -526,7 +540,7 @@ describe('SyncHydrationService', () => {
|
|||
expect(mockOpLogStore.append).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve all other globalConfig properties', async () => {
|
||||
it('should preserve synced globalConfig properties while overlaying local-only sync settings', async () => {
|
||||
const downloadedData = {
|
||||
globalConfig: {
|
||||
lang: 'de',
|
||||
|
|
@ -547,9 +561,10 @@ describe('SyncHydrationService', () => {
|
|||
expect(globalConfig['lang']).toBe('de');
|
||||
expect(globalConfig['theme']).toBe('dark');
|
||||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
expect(sync['syncInterval']).toBe(300);
|
||||
expect(sync['isEnabled']).toBe(true);
|
||||
expect(sync['syncProvider']).toBeNull();
|
||||
expect(sync['isEnabled']).toBe(defaultLocalSyncConfig.isEnabled);
|
||||
expect(sync['syncProvider']).toBe(defaultLocalSyncConfig.syncProvider);
|
||||
expect(sync['syncInterval']).toBe(defaultLocalSyncConfig.syncInterval);
|
||||
expect(sync['isManualSyncOnly']).toBe(defaultLocalSyncConfig.isManualSyncOnly);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -576,6 +591,8 @@ describe('SyncHydrationService', () => {
|
|||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
isEnabled: true,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: false,
|
||||
};
|
||||
mockStore.select.and.returnValue(of(localSyncConfig));
|
||||
|
||||
|
|
@ -607,6 +624,8 @@ describe('SyncHydrationService', () => {
|
|||
// Step 4: Verify the snapshot has PRESERVED local settings (not remote's false)
|
||||
expect(snapshotSync['isEnabled']).toBe(true); // Local value preserved!
|
||||
expect(snapshotSync['syncProvider']).toBe(SyncProviderId.WebDAV); // Local provider preserved!
|
||||
expect(snapshotSync['syncInterval']).toBe(300000);
|
||||
expect(snapshotSync['isManualSyncOnly']).toBe(false);
|
||||
|
||||
// Step 5: Simulate what happens on reload
|
||||
// The hydrator will call store.dispatch(loadAllData({ appDataComplete: snapshotState }))
|
||||
|
|
@ -620,15 +639,19 @@ describe('SyncHydrationService', () => {
|
|||
// Final verification: The data dispatched to NgRx has correct local settings
|
||||
expect(dispatchedSync['isEnabled']).toBe(true);
|
||||
expect(dispatchedSync['syncProvider']).toBe(SyncProviderId.WebDAV);
|
||||
expect(dispatchedSync['syncInterval']).toBe(300000);
|
||||
expect(dispatchedSync['isManualSyncOnly']).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve local isEnabled when remote has it disabled', async () => {
|
||||
it('should preserve local-only sync settings when remote has sync disabled', async () => {
|
||||
// Local has sync enabled
|
||||
mockStore.select.and.returnValue(
|
||||
of({
|
||||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
isEnabled: true,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -638,6 +661,8 @@ describe('SyncHydrationService', () => {
|
|||
sync: {
|
||||
isEnabled: false, // Remote has sync disabled
|
||||
syncProvider: 'dropbox',
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -651,6 +676,42 @@ describe('SyncHydrationService', () => {
|
|||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
expect(sync['isEnabled']).toBe(true);
|
||||
expect(sync['syncProvider']).toBe(SyncProviderId.WebDAV);
|
||||
expect(sync['syncInterval']).toBe(300000);
|
||||
expect(sync['isManualSyncOnly']).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep local sync schedule settings in the local SYNC_IMPORT payload', async () => {
|
||||
mockStore.select.and.returnValue(
|
||||
of({
|
||||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
isEnabled: true,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const downloadedData = {
|
||||
globalConfig: {
|
||||
sync: {
|
||||
isEnabled: false,
|
||||
syncProvider: SyncProviderId.Dropbox,
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await service.hydrateFromRemoteSync(downloadedData);
|
||||
|
||||
const appendCall = mockOpLogStore.append.calls.mostRecent();
|
||||
const payload = appendCall.args[0].payload as Record<string, unknown>;
|
||||
const globalConfig = payload['globalConfig'] as Record<string, unknown>;
|
||||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
|
||||
expect(sync['syncProvider']).toBe(SyncProviderId.WebDAV);
|
||||
expect(sync['syncInterval']).toBe(300000);
|
||||
expect(sync['isManualSyncOnly']).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve local isEnabled when remote has it enabled', async () => {
|
||||
|
|
@ -719,12 +780,64 @@ describe('SyncHydrationService', () => {
|
|||
expect(sync['isEnabled']).toBe(true);
|
||||
});
|
||||
|
||||
// Round-trip pin (issue #8233): iterates LOCAL_ONLY_SYNC_KEYS so adding a
|
||||
// new local-only key in the util grows coverage here automatically without
|
||||
// touching this spec.
|
||||
it('preserves every LOCAL_ONLY_SYNC_KEYS value through hydration (round-trip)', async () => {
|
||||
const localSync = {
|
||||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
isEnabled: true,
|
||||
isEncryptionEnabled: false,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
};
|
||||
const remoteSync = {
|
||||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
isEnabled: false,
|
||||
isEncryptionEnabled: true,
|
||||
syncProvider: SyncProviderId.Dropbox,
|
||||
syncInterval: 60000,
|
||||
isManualSyncOnly: false,
|
||||
};
|
||||
mockStore.select.and.returnValue(of(localSync));
|
||||
|
||||
await service.hydrateFromRemoteSync({
|
||||
task: {},
|
||||
globalConfig: { sync: remoteSync },
|
||||
});
|
||||
|
||||
const dispatchedAction = mockStore.dispatch.calls.mostRecent()
|
||||
.args[0] as unknown as ReturnType<typeof loadAllData>;
|
||||
const dispatchedSync = (
|
||||
dispatchedAction.appDataComplete.globalConfig as Record<string, unknown>
|
||||
)['sync'] as Record<string, unknown>;
|
||||
for (const key of LOCAL_ONLY_SYNC_KEYS) {
|
||||
expect(dispatchedSync[key])
|
||||
.withContext(`dispatched sync.${key} must match local`)
|
||||
.toBe(localSync[key]);
|
||||
}
|
||||
|
||||
const savedState = mockOpLogStore.saveStateCache.calls.mostRecent().args[0]
|
||||
.state as Record<string, unknown>;
|
||||
const savedSync = (savedState['globalConfig'] as Record<string, unknown>)[
|
||||
'sync'
|
||||
] as Record<string, unknown>;
|
||||
for (const key of LOCAL_ONLY_SYNC_KEYS) {
|
||||
expect(savedSync[key])
|
||||
.withContext(`saved snapshot sync.${key} must match local`)
|
||||
.toBe(localSync[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve local settings in both snapshot and dispatch', async () => {
|
||||
mockStore.select.and.returnValue(
|
||||
of({
|
||||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
isEnabled: true,
|
||||
syncProvider: SyncProviderId.SuperSync,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -733,6 +846,8 @@ describe('SyncHydrationService', () => {
|
|||
sync: {
|
||||
isEnabled: false,
|
||||
syncProvider: SyncProviderId.LocalFile,
|
||||
syncInterval: 600000,
|
||||
isManualSyncOnly: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -746,6 +861,8 @@ describe('SyncHydrationService', () => {
|
|||
const savedSync = savedGlobalConfig['sync'] as Record<string, unknown>;
|
||||
expect(savedSync['isEnabled']).toBe(true);
|
||||
expect(savedSync['syncProvider']).toBe(SyncProviderId.SuperSync);
|
||||
expect(savedSync['syncInterval']).toBe(300000);
|
||||
expect(savedSync['isManualSyncOnly']).toBe(true);
|
||||
|
||||
// Check dispatch
|
||||
const dispatchCall = mockStore.dispatch.calls.mostRecent();
|
||||
|
|
@ -757,6 +874,8 @@ describe('SyncHydrationService', () => {
|
|||
const dispatchedSync = dispatchedGlobalConfig['sync'] as Record<string, unknown>;
|
||||
expect(dispatchedSync['isEnabled']).toBe(true);
|
||||
expect(dispatchedSync['syncProvider']).toBe(SyncProviderId.SuperSync);
|
||||
expect(dispatchedSync['syncInterval']).toBe(300000);
|
||||
expect(dispatchedSync['isManualSyncOnly']).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ import { T } from '../../t.const';
|
|||
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
|
||||
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
|
||||
import { normalizeGlobalConfigStartOfNextDay } from '../../features/config/normalize-start-of-next-day-config';
|
||||
import {
|
||||
applyLocalOnlySyncSettingsToAppData,
|
||||
stripLocalOnlySyncSettingsFromAppData,
|
||||
} from '../../features/config/local-only-sync-settings.util';
|
||||
|
||||
/**
|
||||
* Handles hydration after remote sync downloads.
|
||||
|
|
@ -84,7 +88,10 @@ export class SyncHydrationService {
|
|||
const currentSyncConfig = await firstValueFrom(this.store.select(selectSyncConfig));
|
||||
const localOnlySettings = {
|
||||
isEnabled: currentSyncConfig.isEnabled,
|
||||
isEncryptionEnabled: currentSyncConfig.isEncryptionEnabled,
|
||||
syncProvider: currentSyncConfig.syncProvider,
|
||||
syncInterval: currentSyncConfig.syncInterval,
|
||||
isManualSyncOnly: currentSyncConfig.isManualSyncOnly,
|
||||
};
|
||||
|
||||
// 1. Write archives to IndexedDB if they were included in the downloaded data.
|
||||
|
|
@ -117,7 +124,11 @@ export class SyncHydrationService {
|
|||
? { ...dbData, ...downloadedMainModelData }
|
||||
: dbData;
|
||||
|
||||
const syncedData = this._stripLocalOnlySettings(mergedData);
|
||||
const syncedData = stripLocalOnlySyncSettingsFromAppData(mergedData);
|
||||
const locallyReplayableSyncedData = applyLocalOnlySyncSettingsToAppData(
|
||||
syncedData,
|
||||
localOnlySettings,
|
||||
);
|
||||
OpLog.normal(
|
||||
'SyncHydrationService: Loaded synced data',
|
||||
downloadedMainModelData
|
||||
|
|
@ -171,7 +182,7 @@ export class SyncHydrationService {
|
|||
actionType: ActionType.LOAD_ALL_DATA,
|
||||
opType: OpType.SyncImport,
|
||||
entityType: 'ALL',
|
||||
payload: syncedData,
|
||||
payload: locallyReplayableSyncedData,
|
||||
clientId: clientId,
|
||||
vectorClock: newClock,
|
||||
timestamp: Date.now(),
|
||||
|
|
@ -224,7 +235,7 @@ export class SyncHydrationService {
|
|||
// can refuse IN_SYNC. Without this, snapshot hydration would silently
|
||||
// accept corrupt remote data — a gap not covered by validateAfterSync
|
||||
// since this path bypasses processRemoteOps entirely. (#7330)
|
||||
const downloadedAppData = syncedData as AppDataComplete;
|
||||
const downloadedAppData = locallyReplayableSyncedData as AppDataComplete;
|
||||
const normalizedGlobalConfig = normalizeGlobalConfigStartOfNextDay(
|
||||
downloadedAppData.globalConfig,
|
||||
);
|
||||
|
|
@ -249,23 +260,6 @@ export class SyncHydrationService {
|
|||
this.sessionValidation.setFailed();
|
||||
}
|
||||
|
||||
// 7b. Restore local-only sync settings into dataToLoad
|
||||
// This ensures the snapshot and NgRx state have the correct local settings,
|
||||
// not the ones from remote data. Without this, isEnabled could be set to false
|
||||
// if another client had sync disabled, causing sync to appear disabled on reload.
|
||||
if (dataToLoad.globalConfig?.sync) {
|
||||
dataToLoad = {
|
||||
...dataToLoad,
|
||||
globalConfig: {
|
||||
...dataToLoad.globalConfig,
|
||||
sync: {
|
||||
...dataToLoad.globalConfig.sync,
|
||||
...localOnlySettings,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 8. Determine the working clock to use.
|
||||
// When a SYNC_IMPORT was created, reset to minimal (only importing client's entry)
|
||||
// to prevent dead client IDs from accumulating. The SYNC_IMPORT operation stores
|
||||
|
|
@ -311,41 +305,4 @@ export class SyncHydrationService {
|
|||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips local-only settings from synced data to prevent them from being
|
||||
* overwritten by remote data. These settings should remain local to each client.
|
||||
*
|
||||
* Currently strips:
|
||||
* - globalConfig.sync.syncProvider: Each client chooses its own sync provider
|
||||
*/
|
||||
private _stripLocalOnlySettings(data: unknown): unknown {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return data;
|
||||
}
|
||||
|
||||
const typedData = data as Record<string, unknown>;
|
||||
if (!typedData['globalConfig']) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const globalConfig = typedData['globalConfig'] as Record<string, unknown>;
|
||||
if (!globalConfig['sync']) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
|
||||
// Return data with syncProvider nulled out
|
||||
return {
|
||||
...typedData,
|
||||
globalConfig: {
|
||||
...globalConfig,
|
||||
sync: {
|
||||
...sync,
|
||||
syncProvider: null, // Local-only setting, don't overwrite from remote
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { getSyncFilePrefix } from '../../util/sync-file-prefix';
|
|||
import { ArchiveDbAdapter } from '../../../core/persistence/archive-db-adapter.service';
|
||||
import { ArchiveModel } from '../../../features/time-tracking/time-tracking.model';
|
||||
import { StateSnapshotService } from '../../backup/state-snapshot.service';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../../features/config/default-global-config.const';
|
||||
|
||||
describe('FileBasedSyncAdapterService', () => {
|
||||
let service: FileBasedSyncAdapterService;
|
||||
|
|
@ -137,6 +138,16 @@ describe('FileBasedSyncAdapterService', () => {
|
|||
mockStateSnapshotService.getStateSnapshot.and.returnValue({
|
||||
tasks: [],
|
||||
projects: [],
|
||||
globalConfig: {
|
||||
...DEFAULT_GLOBAL_CONFIG,
|
||||
sync: {
|
||||
...DEFAULT_GLOBAL_CONFIG.sync,
|
||||
syncProvider: SyncProviderId.WebDAV,
|
||||
syncInterval: 300000,
|
||||
isManualSyncOnly: true,
|
||||
isCompressionEnabled: true,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
|
|
@ -684,7 +695,16 @@ describe('FileBasedSyncAdapterService', () => {
|
|||
expect(result.accepted).toBe(true);
|
||||
const uploadedData = parseWithPrefix(uploadedDataStr);
|
||||
// State should come from getStateSnapshot(), not the passed parameter
|
||||
expect(uploadedData.state).toEqual({ tasks: [], projects: [] } as any);
|
||||
expect(uploadedData.state).toEqual(
|
||||
jasmine.objectContaining({ tasks: [], projects: [] }) as any,
|
||||
);
|
||||
const uploadedState = uploadedData.state as Record<string, unknown>;
|
||||
const globalConfig = uploadedState['globalConfig'] as Record<string, unknown>;
|
||||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
expect(sync['syncProvider']).toBeNull();
|
||||
expect(sync['syncInterval']).toBeUndefined();
|
||||
expect(sync['isManualSyncOnly']).toBeUndefined();
|
||||
expect(sync['isCompressionEnabled']).toBe(true);
|
||||
expect(uploadedData.vectorClock).toEqual(vectorClock);
|
||||
expect(uploadedData.schemaVersion).toBe(2);
|
||||
expect(uploadedData.syncVersion).toBe(1);
|
||||
|
|
@ -1019,6 +1039,12 @@ describe('FileBasedSyncAdapterService', () => {
|
|||
const uploadedData = parseWithPrefix(uploadedDataStr);
|
||||
expect(uploadedData.archiveYoung).toBeUndefined();
|
||||
expect(uploadedData.archiveOld).toBeUndefined();
|
||||
const uploadedState = uploadedData.state as Record<string, unknown>;
|
||||
const globalConfig = uploadedState['globalConfig'] as Record<string, unknown>;
|
||||
const sync = globalConfig['sync'] as Record<string, unknown>;
|
||||
expect(sync['syncProvider']).toBeNull();
|
||||
expect(sync['syncInterval']).toBeUndefined();
|
||||
expect(sync['isManualSyncOnly']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
import { mergeVectorClocks } from '../../../core/util/vector-clock';
|
||||
import { ArchiveDbAdapter } from '../../../core/persistence/archive-db-adapter.service';
|
||||
import { StateSnapshotService } from '../../backup/state-snapshot.service';
|
||||
import { stripLocalOnlySyncSettingsFromAppData } from '../../../features/config/local-only-sync-settings.util';
|
||||
|
||||
/**
|
||||
* Adapter that enables file-based sync providers (WebDAV, Dropbox, LocalFile)
|
||||
|
|
@ -406,7 +407,9 @@ export class FileBasedSyncAdapterService {
|
|||
const archiveOld = await this._archiveDbAdapter.loadArchiveOld();
|
||||
|
||||
// Get current state from NgRx store - this keeps the snapshot up-to-date
|
||||
const currentState = await this._stateSnapshotService.getStateSnapshot();
|
||||
const currentState = stripLocalOnlySyncSettingsFromAppData(
|
||||
await this._stateSnapshotService.getStateSnapshot(),
|
||||
);
|
||||
|
||||
// Compute oldestOpSyncVersion from the first (oldest) op in mergedOps.
|
||||
// Ops without sv (from old sync files) are ignored — gap detection stays
|
||||
|
|
@ -783,7 +786,9 @@ export class FileBasedSyncAdapterService {
|
|||
// sync cycle, so fetching directly ensures we capture the latest store state.
|
||||
// Note: double-encryption is not a concern here — file-based providers don't expose
|
||||
// getEncryptKey, so the upload service never applies payload-level encryption for them.
|
||||
const currentState = await this._stateSnapshotService.getStateSnapshot();
|
||||
const currentState = stripLocalOnlySyncSettingsFromAppData(
|
||||
await this._stateSnapshotService.getStateSnapshot(),
|
||||
);
|
||||
|
||||
// Load archive data from IndexedDB to include in snapshot
|
||||
const archiveYoung = await this._archiveDbAdapter.loadArchiveYoung();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue