Merge branch 'master' into feat/operation-logs

* master:
  fix(test): use dynamic date in year boundary test to avoid today collision
  fix(electron): reduce idle detection log verbosity
  fix(plugins): ensure setCounter creates valid SimpleCounter records
  feat(android): add quick add widget
  perf(android): prewarm WebView during idle time to speed up startup
  fix(ical): prevent race condition in lazy loader
  Merge branch 'master' into master

# Conflicts:
#	src/app/features/android/store/android.effects.ts
#	src/app/features/schedule/ical/get-relevant-events-from-ical.spec.ts
This commit is contained in:
Johannes Millan 2025-12-31 14:20:58 +01:00
commit bb254dd07b
23 changed files with 620 additions and 43 deletions

View file

@ -107,5 +107,25 @@
android:name=".receiver.ReminderActionReceiver"
android:enabled="true"
android:exported="false" />
<!-- Quick Add Widget -->
<receiver
android:name=".widget.QuickAddWidgetProvider"
android:exported="true"
android:label="@string/widget_quick_add_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_quick_add_info" />
</receiver>
<!-- Quick Add Dialog Activity -->
<activity
android:name=".widget.QuickAddActivity"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light.Dialog"
android:excludeFromRecents="true" />
</application>
</manifest>

View file

@ -1,16 +1,15 @@
package com.superproductivity.superproductivity
import android.app.Application
import android.os.Looper
import android.os.MessageQueue
import android.util.Log
import android.webkit.WebSettings
import com.superproductivity.superproductivity.app.AppLifecycleObserver
import com.superproductivity.superproductivity.app.KeyValStore
class App : Application() {
// NOTE using the web view like this causes all html5 inputs not to work
// val wv: WebView by lazy {
// WebHelper().instanceView(this)
// }
val keyValStore: KeyValStore by lazy {
KeyValStore(this)
}
@ -20,5 +19,36 @@ class App : Application() {
// Initialize AppLifecycleObserver at app startup
AppLifecycleObserver.getInstance()
// Prewarm WebView by loading native libraries during idle time
prewarmWebView()
}
/**
* Prewarms the WebView's Chromium engine by triggering native library loading
* during main thread idle time. This reduces the 200-300ms freeze that occurs
* on first WebView creation.
*
* Uses WebSettings.getDefaultUserAgent() as recommended by the Chromium team -
* it loads WebView libraries without side effects.
*/
private fun prewarmWebView() {
try {
Looper.getMainLooper().queue.addIdleHandler(object : MessageQueue.IdleHandler {
override fun queueIdle(): Boolean {
try {
val startTime = System.currentTimeMillis()
WebSettings.getDefaultUserAgent(this@App)
val duration = System.currentTimeMillis() - startTime
Log.d("SP-WebView", "WebView prewarmed in ${duration}ms")
} catch (e: Exception) {
Log.w("SP-WebView", "WebView prewarm failed: ${e.message}")
}
return false // Remove handler after execution
}
})
} catch (e: Exception) {
Log.w("SP-WebView", "Failed to schedule WebView prewarm: ${e.message}")
}
}
}

View file

@ -13,6 +13,7 @@ import com.superproductivity.superproductivity.app.LaunchDecider
import com.superproductivity.superproductivity.service.FocusModeForegroundService
import com.superproductivity.superproductivity.service.ReminderNotificationHelper
import com.superproductivity.superproductivity.service.TrackingForegroundService
import com.superproductivity.superproductivity.widget.WidgetTaskQueue
class JavaScriptInterface(
@ -192,6 +193,16 @@ class JavaScriptInterface(
ReminderNotificationHelper.cancelReminder(activity, notificationId)
}
/**
* Get queued tasks from the widget and clear the queue.
* Returns JSON string of tasks or null if empty.
*/
@Suppress("unused")
@JavascriptInterface
fun getWidgetTaskQueue(): String? {
return WidgetTaskQueue.getAndClearQueue(activity)
}
fun callJavaScriptFunction(script: String) {
webView.post { webView.evaluateJavascript(script) { } }
}

View file

@ -0,0 +1,64 @@
package com.superproductivity.superproductivity.widget
import android.app.Activity
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import com.superproductivity.superproductivity.R
/**
* A minimal floating dialog activity for quick task entry from the widget.
* Uses a dialog theme to appear as a floating window.
*/
class QuickAddActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_quick_add)
// Make it dialog-like with proper sizing
window.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
setFinishOnTouchOutside(true)
val editText = findViewById<EditText>(R.id.quick_add_input)
val addButton = findViewById<Button>(R.id.quick_add_submit)
addButton.setOnClickListener {
submitTask(editText)
}
// Handle keyboard "done" action
editText.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_DONE ||
(event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)
) {
submitTask(editText)
true
} else {
false
}
}
// Show keyboard automatically
editText.requestFocus()
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
}
private fun submitTask(editText: EditText) {
val title = editText.text.toString().trim()
if (title.isNotEmpty()) {
WidgetTaskQueue.addTask(this, title)
Toast.makeText(this, R.string.widget_task_added, Toast.LENGTH_SHORT).show()
finish()
} else {
Toast.makeText(this, R.string.widget_enter_title, Toast.LENGTH_SHORT).show()
}
}
}

View file

@ -0,0 +1,75 @@
package com.superproductivity.superproductivity.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import com.superproductivity.superproductivity.R
/**
* Widget provider for the quick-add task widget.
* Displays a button that opens the QuickAddActivity dialog.
*/
class QuickAddWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (widgetId in appWidgetIds) {
updateWidget(context, appWidgetManager, widgetId)
}
}
override fun onEnabled(context: Context) {
// Called when the first widget is created
}
override fun onDisabled(context: Context) {
// Called when the last widget is removed
}
companion object {
/**
* Update all instances of this widget.
*/
fun updateAllWidgets(context: Context) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetIds = appWidgetManager.getAppWidgetIds(
ComponentName(context, QuickAddWidgetProvider::class.java)
)
for (widgetId in widgetIds) {
updateWidget(context, appWidgetManager, widgetId)
}
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
widgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.widget_quick_add)
// Create intent to open QuickAddActivity
val intent = Intent(context, QuickAddActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
widgetId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// Set click handler on the entire widget
views.setOnClickPendingIntent(R.id.widget_container, pendingIntent)
appWidgetManager.updateAppWidget(widgetId, views)
}
}
}

View file

@ -0,0 +1,76 @@
package com.superproductivity.superproductivity.widget
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
/**
* Manages a queue of tasks created from the home screen widget.
* Tasks are stored in SharedPreferences and processed when the Angular app resumes.
*/
object WidgetTaskQueue {
private const val PREFS_NAME = "SuperProductivityWidget"
private const val KEY_TASK_QUEUE = "WIDGET_TASK_QUEUE"
private fun getPrefs(context: Context): SharedPreferences {
return context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
/**
* Add a task to the queue.
* @return The generated task ID
*/
fun addTask(context: Context, title: String): String {
val taskId = UUID.randomUUID().toString()
val task = JSONObject().apply {
put("id", taskId)
put("title", title.trim())
put("createdAt", System.currentTimeMillis())
}
val prefs = getPrefs(context)
val queueJson = prefs.getString(KEY_TASK_QUEUE, null)
val queue = if (queueJson != null) {
try {
JSONObject(queueJson)
} catch (e: Exception) {
JSONObject().put("tasks", JSONArray())
}
} else {
JSONObject().put("tasks", JSONArray())
}
val tasks = queue.optJSONArray("tasks") ?: JSONArray()
tasks.put(task)
queue.put("tasks", tasks)
prefs.edit().putString(KEY_TASK_QUEUE, queue.toString()).commit()
return taskId
}
/**
* Get all queued tasks and clear the queue atomically.
* @return JSON string of queued tasks, or null if empty
*/
fun getAndClearQueue(context: Context): String? {
val prefs = getPrefs(context)
val queueJson = prefs.getString(KEY_TASK_QUEUE, null)
if (queueJson != null) {
prefs.edit().remove(KEY_TASK_QUEUE).commit()
try {
val queue = JSONObject(queueJson)
val tasks = queue.optJSONArray("tasks")
if (tasks != null && tasks.length() > 0) {
return queueJson
}
} catch (e: Exception) {
// Invalid JSON, return null
}
}
return null
}
}

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Circle background -->
<item>
<shape android:shape="oval">
<solid android:color="#0B77D2" />
<stroke
android:width="2dp"
android:color="#FFFFFF" />
</shape>
</item>
<!-- Horizontal bar of plus -->
<item
android:top="11dp"
android:bottom="11dp"
android:left="5dp"
android:right="5dp">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="1dp" />
</shape>
</item>
<!-- Vertical bar of plus -->
<item
android:top="5dp"
android:bottom="5dp"
android:left="11dp"
android:right="11dp">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="1dp" />
</shape>
</item>
</layer-list>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E0FFFFFF" />
<corners android:radius="16dp" />
</shape>

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- App icon as base -->
<item
android:drawable="@mipmap/ic_launcher"
android:gravity="center" />
<!-- Plus badge in bottom-right corner -->
<item
android:width="20dp"
android:height="20dp"
android:gravity="bottom|end"
android:bottom="2dp"
android:end="2dp">
<shape android:shape="oval">
<solid android:color="#0B77D2" />
<stroke
android:width="1dp"
android:color="#FFFFFF" />
</shape>
</item>
<!-- Plus sign -->
<item
android:width="20dp"
android:height="20dp"
android:gravity="bottom|end"
android:bottom="2dp"
android:end="2dp">
<layer-list>
<!-- Horizontal bar -->
<item
android:width="10dp"
android:height="2dp"
android:gravity="center">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="1dp" />
</shape>
</item>
<!-- Vertical bar -->
<item
android:width="2dp"
android:height="10dp"
android:gravity="center">
<shape android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="1dp" />
</shape>
</item>
</layer-list>
</item>
</layer-list>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/widget_dialog_title"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<EditText
android:id="@+id/quick_add_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/widget_input_hint"
android:inputType="text"
android:imeOptions="actionDone"
android:maxLines="1"
android:layout_marginBottom="16dp" />
<Button
android:id="@+id/quick_add_submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_add_button" />
</LinearLayout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:padding="4dp">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@mipmap/ic_launcher"
android:scaleType="fitCenter"
android:contentDescription="@string/widget_add_task" />
<!-- Plus badge overlay -->
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="bottom|end"
android:layout_margin="2dp"
android:src="@drawable/ic_widget_plus_badge"
android:contentDescription="@null" />
</FrameLayout>

View file

@ -7,4 +7,15 @@
<string name="webview_block_provider">Provider package: %1$s</string>
<string name="webview_block_update">Update WebView</string>
<string name="webview_block_close">Close app</string>
<!-- Widget strings -->
<string name="widget_quick_add_label">Quick Add Task</string>
<string name="widget_description">Quickly add a task without opening the app</string>
<string name="widget_tap_to_add">Tap to add a task</string>
<string name="widget_add_task">Add task</string>
<string name="widget_dialog_title">Add Task</string>
<string name="widget_input_hint">What do you need to do?</string>
<string name="widget_add_button">Add</string>
<string name="widget_task_added">Task added</string>
<string name="widget_enter_title">Please enter a task title</string>
</resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:targetCellWidth="1"
android:targetCellHeight="1"
android:resizeMode="none"
android:widgetCategory="home_screen"
android:initialLayout="@layout/widget_quick_add"
android:previewImage="@mipmap/ic_launcher"
android:description="@string/widget_description"
android:updatePeriodMillis="0" />

View file

@ -63,7 +63,7 @@ export class IdleTimeHandler {
gnomeShellSession,
};
log.info('Environment detection:', environment);
log.debug('Environment detection:', environment);
return environment;
}
@ -138,22 +138,22 @@ export class IdleTimeHandler {
}
private async _determineWorkingMethod(): Promise<IdleDetectionMethod> {
log.info('Determining idle detection method...');
log.debug('Determining idle detection method...');
if (!this._environment.isWayland) {
log.info('Using powerMonitor for non-Wayland session');
log.debug('Using powerMonitor for non-Wayland session');
return 'powerMonitor';
}
for (const candidate of this._buildWaylandCandidates()) {
log.info(`Testing ${candidate.name}...`);
log.debug(`Testing ${candidate.name}...`);
try {
const works = await candidate.test();
if (works) {
log.info(`Selected ${candidate.name} for idle detection`);
return candidate.name;
}
log.info(`${candidate.name} test failed`);
log.debug(`${candidate.name} test failed`);
} catch (error) {
log.warn(`${candidate.name} test error`, error);
}
@ -177,7 +177,7 @@ export class IdleTimeHandler {
return idleTime !== null;
} catch (error) {
if (this._environment.isSnap) {
log.info('GNOME DBus test failed in snap environment', error);
log.debug('GNOME DBus test failed in snap environment', error);
}
return false;
}
@ -198,7 +198,7 @@ export class IdleTimeHandler {
});
if (this._environment.isSnap) {
log.info('Skipping loginctl in snap environment');
log.debug('Skipping loginctl in snap environment');
} else {
candidates.push({
name: 'loginctl',

View file

@ -233,7 +233,7 @@ export const startApp = (): void => {
`threshold=${CONFIG.MIN_IDLE_TIME}ms`,
`action=${actionSummary}`,
];
log(`🕘 Idle check (${logParts.join(', ')})`);
electronLog.debug(`🕘 Idle check (${logParts.join(', ')})`);
} catch (error) {
consecutiveFailures += 1;
log('💥 Error getting idle time, falling back to powerMonitor:', error);

View file

@ -73,6 +73,9 @@ export interface AndroidInterface {
): void;
cancelNativeReminder?(notificationId: number): void;
// Widget task queue - get queued tasks from home screen widget
getWidgetTaskQueue?(): string | null;
// added here only
onResume$: Subject<void>;
onPause$: Subject<void>;

View file

@ -173,6 +173,43 @@ export class AndroidEffects {
{ dispatch: false },
);
// Process tasks queued from the home screen widget
processWidgetTasks$ =
IS_ANDROID_WEB_VIEW &&
createEffect(
() =>
androidInterface.onResume$.pipe(
tap(() => {
const queueJson = androidInterface.getWidgetTaskQueue?.();
if (!queueJson) {
return;
}
try {
const queue = JSON.parse(queueJson);
const tasks = queue.tasks || [];
for (const widgetTask of tasks) {
this._taskService.add(widgetTask.title);
}
if (tasks.length > 0) {
this._snackService.open({
type: 'SUCCESS',
msg:
tasks.length === 1
? 'Task added from widget'
: `${tasks.length} tasks added from widget`,
});
}
} catch (e) {
DroidLog.err('Failed to process widget tasks', e);
}
}),
),
{ dispatch: false },
);
private async _ensureExactAlarmAccess(): Promise<void> {
try {
if (this._hasCheckedExactAlarm) {

View file

@ -827,7 +827,7 @@ SUMMARY:Office 365 Meeting
END:VEVENT
END:VCALENDAR`;
// Should not throw an error - the test will fail if it throws
// Should not throw an error
const events = await getRelevantEventsForCalendarIntegrationFromIcal(
icalData,
calProviderId,

View file

@ -0,0 +1,35 @@
import { loadIcalModule } from './ical-lazy-loader';
describe('ical-lazy-loader', () => {
describe('loadIcalModule', () => {
it('should load the ical.js module', async () => {
const ICAL = await loadIcalModule();
expect(ICAL).toBeDefined();
expect(ICAL.parse).toBeDefined();
expect(ICAL.Component).toBeDefined();
});
it('should return the same module instance on subsequent calls', async () => {
const first = await loadIcalModule();
const second = await loadIcalModule();
expect(first).toBe(second);
});
it('should handle concurrent calls without race conditions', async () => {
const results = await Promise.all([
loadIcalModule(),
loadIcalModule(),
loadIcalModule(),
loadIcalModule(),
loadIcalModule(),
]);
const firstResult = results[0];
results.forEach((result) => {
expect(result).toBe(firstResult);
});
});
});
});

View file

@ -5,18 +5,26 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let icalModule: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let loadingPromise: Promise<any> | null = null;
/**
* Lazily loads the ical.js module on first use.
* Subsequent calls return the cached module.
* Concurrent calls share the same loading promise to prevent race conditions.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const loadIcalModule = async (): Promise<any> => {
if (!icalModule) {
// @ts-ignore - ical.js exports default
const mod = await import('ical.js');
// Handle both ESM default export and CommonJS module.exports
icalModule = mod.default || mod;
if (icalModule) {
return icalModule;
}
return icalModule;
if (!loadingPromise) {
loadingPromise = import('ical.js').then((mod) => {
// @ts-ignore - ical.js exports default
// Handle both ESM default export and CommonJS module.exports
icalModule = mod.default || mod;
return icalModule;
});
}
return loadingPromise;
};

View file

@ -893,8 +893,11 @@ describe('AddTaskBarActionsComponent', () => {
});
it('should handle dates near year boundaries correctly', () => {
// Test New Year's Eve
const newYearEve = '2025-12-31';
// Test a date near year boundary that's not today or tomorrow
// Use next year's New Year's Eve to avoid collision with current date
const today = new Date();
const nextYear = today.getFullYear() + 1;
const newYearEve = `${nextYear}-12-31`;
const stateWithNewYearEve = {
...mockState,
date: newYearEve,

View file

@ -55,7 +55,11 @@ import { MAX_BATCH_OPERATIONS_SIZE } from '../op-log/core/operation-log.const';
// New imports for simple counters
import { selectAllSimpleCounters } from '../features/simple-counter/store/simple-counter.reducer';
import { SimpleCounter } from '../features/simple-counter/simple-counter.model';
import {
SimpleCounter,
SimpleCounterType,
} from '../features/simple-counter/simple-counter.model';
import { EMPTY_SIMPLE_COUNTER } from '../features/simple-counter/simple-counter.const';
import {
upsertSimpleCounter,
updateSimpleCounter,
@ -1250,20 +1254,40 @@ export class PluginBridgeService implements OnDestroy {
throw new Error('Invalid counter value: must be a non-negative number');
}
const today = new Date().toISOString().split('T')[0];
// Upsert the counter (creates if not exists)
this._store.dispatch(
upsertSimpleCounter({
simpleCounter: {
id: id,
//title: id,
//isEnabled: true,
//icon: null,
//type: 'ClickCounter',
countOnDay: { [today]: value },
//isOn: false,
} as SimpleCounter,
}),
);
// Check if counter already exists
const existingCounter = await this.getSimpleCounter(id);
if (existingCounter) {
// Update existing counter's countOnDay only
this._store.dispatch(
updateSimpleCounter({
simpleCounter: {
id,
changes: {
countOnDay: {
...existingCounter.countOnDay,
[today]: value,
},
},
},
}),
);
} else {
// Create new counter with all mandatory fields
this._store.dispatch(
upsertSimpleCounter({
simpleCounter: {
...EMPTY_SIMPLE_COUNTER,
id,
title: id,
isEnabled: true,
type: SimpleCounterType.ClickCounter,
countOnDay: { [today]: value },
},
}),
);
}
}
async incrementCounter(id: string, incrementBy = 1): Promise<number> {

View file

@ -31,7 +31,7 @@
"SHOW_ISSUE_PANEL": "显示问题看板",
"SHOW_NOTES": "显示项目备注",
"SHOW_TASK_VIEW_CUSTOMIZER_PANEL": "显示筛选/分组/排序看板"
},
},
"CONFIRM": {
"AUTO_FIX": "您的数据似乎已损坏(“{{validityError}}”)。您想尝试自动修复吗?这可能会导致部分数据丢失。",
"RELOAD_AFTER_IDB_ERROR": "无法访问数据库 :( 可能的原因是在后台更新了应用或磁盘空间不足。如果您在 Linux 上将应用安装为 snap您还需要启用刷新感知 'snap set core experimental.refresh-app-awareness=true',直到他们在他们那边修复此问题。按 OK 重新加载应用(在某些平台上可能需要手动重新启动应用)。",
@ -217,9 +217,14 @@
"ADD_TIME_MINUTE": "增加一分钟",
"B": {
"BREAK_RUNNING": "休息正在进行",
"POMODORO_SESSION_RUNNING": "番茄会话 #{{cycleNr}} 正在进行",
"END_BREAK": "休息结束",
"END_SESSION": "结束会话",
"PAUSE": "暂停",
"POMODORO_BREAK_RUNNING": "休息 #{{cycleNr}} 正在进行",
"POMODORO_SESSION_RUNNING": "番茄会话 #{{cycleNr}} 正在进行",
"RESUME": "恢复",
"SESSION_RUNNING": "专注会话正在进行",
"START": "开始",
"TO_FOCUS_OVERLAY": "到专注覆盖"
},
"BACK_TO_PLANNING": "返回规划",
@ -234,7 +239,7 @@
"COUNTDOWN_HINT": "专注直到时钟归零",
"CURRENT_SESSION_TIME_TOOLTIP": "当前会话时间",
"FINISH_TASK_AND_SELECT_NEXT": "完成任务并选择下一个",
"FLOWTIME": "Flowtime",
"FLOWTIME": "流程时间",
"FLOWTIME_HINT": "无严格计时器的灵活节奏",
"FOCUS_TIME_TOOLTIP": "专注时间",
"FOR_TASK": "对于任务",
@ -1136,11 +1141,15 @@
"TITLE": "同步",
"WEB_DAV": {
"CORS_INFO": "<strong>使其在浏览器中工作:</strong> 要使其在浏览器中工作,您需要将超级生产力加入 WebDAV 服务器的跨域资源共享请求白名单。这可能会带来负面的安全影响!对于 nextcloud请 <a href='https://github.com/nextcloud/server/issues/3131 '>参考此线程以获取更多信息</a>。一种在移动设备上实现此目的的方法是通过 nextcloud 应用 <a href='https://apps.nextcloud.com/apps/webapppassword '>webapppassword<a> 将 \"https://app.super-productivity.com \" 加入白名单。使用风险自负!</p>",
"D_SYNC_FOLDER_PATH": "同步文件在WebDAV服务器根目录下的相对存储路径例如'/super-productivity' 或 '/')。请注意,这不是您服务器内部的文件目录路径。",
"INFO": "WebDAV 实现差异很大。众所周知Super Productivity 与 Nextcloud 配合良好,<strong>但它可能不适用于您的提供者</strong>。",
"L_BASE_URL": "基本 URL",
"L_PASSWORD": "密码",
"L_SYNC_FOLDER_PATH": "同步文件夹路径",
"L_USER_NAME": "用户名"
"L_TEST_CONNECTION": "测试链接",
"L_USER_NAME": "用户名",
"S_TEST_FAIL": "连接测试失败:{{error}} - 目标URL{{url}}",
"S_TEST_SUCCESS": "连接测试成功目标URL{{url}}"
}
},
"S": {
@ -1150,6 +1159,9 @@
"BTN_FORCE_OVERWRITE": "强制覆盖",
"ERROR_CORS": "WebDAV 同步错误: 网络请求失败。\n\n这可能是一个跨域资源共享问题。请确保\n• 您的 WebDAV 服务器允许跨源请求\n• 服务器 URL 正确且可访问\n• 您有有效的互联网连接",
"ERROR_DATA_IS_CURRENTLY_WRITTEN": "远程数据当前正在写入",
"ERROR_PERMISSION": "文件访问被拒绝。如果正在使用 Flatpak/Snap ,请通过 Flatseal 授予文件系统权限,或者使用 ~/.var/app/ 目录内的路径。",
"ERROR_PERMISSION_FLATPAK": "文件访问被拒绝。请通过Flatseal授予文件系统权限或使用路径 ~/.var/app/ 内的目录。",
"ERROR_PERMISSION_SNAP": "文件访问被拒绝。请运行命令 'snap connect super-productivity:home',或使用路径 ~/snap/super-productivity/common/ 内的目录。",
"ERROR_FALLBACK_TO_BACKUP": "导入数据时出错。回退到本地备份。",
"ERROR_INVALID_DATA": "同步时出错。数据无效",
"ERROR_NO_REV": "远程文件没有有效的修订",
@ -1751,6 +1763,10 @@
"HELP": "专注模式打开一个无干扰的屏幕,以帮助您专注于当前任务。",
"L_ALWAYS_OPEN_FOCUS_MODE": "始终在跟踪时打开专注模式",
"L_SKIP_PREPARATION_SCREEN": "跳过准备屏幕(拉伸等)",
"L_IS_PLAY_TICK": "专注时播放滴答声",
"L_PAUSE_TRACKING_DURING_BREAK": "休息期间暂停任务跟踪",
"L_SKIP_PREPARATION_SCREEN": "跳过准备界面(火箭动画)",
"L_START_IN_BACKGROUND": "仅以横幅开始专注时段(无覆盖层)",
"TITLE": "专注模式"
},
"IDLE": {
@ -1887,8 +1903,8 @@
"IS_TRAY_SHOW_CURRENT_COUNTDOWN": "在托盘/状态菜单中显示当前倒计时(仅限桌面 Mac",
"IS_TRAY_SHOW_CURRENT_TASK": "在托盘/状态菜单中显示当前任务(仅限桌面 Mac/Windows",
"IS_TURN_OFF_MARKDOWN": "关闭任务描述的 Markdown 解析",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "Use custom title bar (Windows/Linux only)",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "Requires restart to take effect",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR": "使用自定义标题栏仅限Windows/Linux",
"IS_USE_CUSTOM_WINDOW_TITLE_BAR_HINT": "需要重启方可生效",
"START_OF_NEXT_DAY": "下一天的开始时间",
"START_OF_NEXT_DAY_HINT": "从何时(小时)开始计算下一天已经开始。默认是午夜,即 0。",
"TASK_NOTES_TPL": "任务描述模板",