diff --git a/README.md b/README.md
index 5d40f7e..1227f7d 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under
`vendor/zennotes/` with their source identity and checksums (`manifest.json`),
and `package-lock.json` pins the complete install. No source checkout is used.
The vendored set is the core release
-[core-2.77.0-core.hfe38f5ee2a70bd64](https://github.com/ZenNotes/zennotes/releases/tag/core-2.77.0-core.hfe38f5ee2a70bd64)
-(desktop commit `3f0356de`, clean tree). Run `npm run
+[core-2.78.0-core.h0bc32cbd73a78fac](https://github.com/ZenNotes/zennotes/releases/tag/core-2.78.0-core.h0bc32cbd73a78fac)
+(desktop commit `52ab86fd`, clean tree). Run `npm run
boundaries:check` to verify archives, installed versions, singleton
editor/React peers, and imports; it refuses an archive built from a dirty
upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out.
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 847b83a..49a0efe 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -15,8 +15,8 @@ android {
applicationId "md.zennotes"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 50
- versionName "1.1.47"
+ versionCode 51
+ versionName "1.1.48"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index fb95aef..3ab1cbc 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -71,6 +71,27 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_tasks_info" />
+
+
+
+
+
+
+
+
+
{
+ try {
+ getActivity().startActivity(intent);
+ call.resolve();
+ } catch (ActivityNotFoundException e) {
+ call.reject("No app on this phone opens this kind of file.", "NO_APP");
+ }
+ });
+ }
+
+ /** The provider's answer first (a picked folder knows its documents' types),
+ * then the file name's extension, then any type at all. */
+ private static String mimeType(Context context, Uri uri, String raw) {
+ String type = null;
+ try {
+ type = context.getContentResolver().getType(uri);
+ } catch (RuntimeException ignored) {
+ // Fall back to the name below.
+ }
+ if (type != null) return type;
+ String name = Uri.decode(raw);
+ int dot = name.lastIndexOf('.');
+ int slash = name.lastIndexOf('/');
+ if (dot > slash && dot < name.length() - 1) {
+ String ext = name.substring(dot + 1).toLowerCase(Locale.ROOT);
+ type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
+ }
+ return type != null ? type : "*/*";
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/WidgetBridgePlugin.java b/android/app/src/main/java/md/zennotes/WidgetBridgePlugin.java
index aad60d6..01de9c3 100644
--- a/android/app/src/main/java/md/zennotes/WidgetBridgePlugin.java
+++ b/android/app/src/main/java/md/zennotes/WidgetBridgePlugin.java
@@ -10,8 +10,12 @@
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
+import org.json.JSONException;
+import org.json.JSONObject;
+
import java.io.IOException;
+import md.zennotes.widgets.WidgetActions;
import md.zennotes.widgets.WidgetSnapshot;
import md.zennotes.widgets.WidgetUpdater;
@@ -21,7 +25,9 @@
* and ui-mobile/deep-links.ts run unchanged). The WebView publishes the
* widget snapshot; this writes it into the app's private files and
* re-renders every placed widget (md.zennotes.widgets). The widgets never
- * see the vault itself.
+ * see the vault itself, except that the Calendar widget writes a tick into
+ * the task's own line when that line is still the one the snapshot named;
+ * readActions hands its tap log back (src/bridge/widget-calendar.ts).
*/
@CapacitorPlugin(name = "ZenWidgets")
public class WidgetBridgePlugin extends Plugin {
@@ -68,11 +74,36 @@ public void update(PluginCall call) {
call.reject("Could not write the widget snapshot: " + e.getMessage());
return;
}
+ WidgetActions.prune(context, actionsAck(json));
WidgetUpdater.refreshAll(context);
call.resolve();
}, "zn-widgets").start();
}
+ /** The newest Calendar widget tap this snapshot says the app applied. */
+ private static long actionsAck(String json) {
+ try {
+ JSONObject calendar = new JSONObject(json).optJSONObject("calendar");
+ return calendar == null ? 0 : (long) calendar.optDouble("actionsAck", 0);
+ } catch (JSONException e) {
+ return 0;
+ }
+ }
+
+ @PluginMethod
+ public void readActions(PluginCall call) {
+ final Context context = getContext().getApplicationContext();
+ new Thread(() -> {
+ try {
+ JSObject ret = new JSObject();
+ ret.put("actions", new com.getcapacitor.JSArray(WidgetActions.json(context)));
+ call.resolve(ret);
+ } catch (JSONException e) {
+ call.reject("Could not read the widget's taps: " + e.getMessage());
+ }
+ }, "zn-widgets").start();
+ }
+
@PluginMethod
public void clear(PluginCall call) {
final Context context = getContext().getApplicationContext();
diff --git a/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java b/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java
new file mode 100644
index 0000000..17a5780
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java
@@ -0,0 +1,162 @@
+package md.zennotes.widgets;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.ColorStateList;
+import android.graphics.Paint;
+import android.os.Build;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import md.zennotes.R;
+
+/**
+ * The rows of the Calendar widget's week list, the panel's agenda: each day
+ * of the selected day's week as a header ("27 TUE", Today, a count), then
+ * that day's open tasks. Built in one place for both ways the list is fed
+ * (CalendarWidgetProvider's collection items on Android 12+, the
+ * RemoteViewsService before), so they agree on rows and positions.
+ */
+final class CalendarAgenda {
+ private CalendarAgenda() {}
+
+ static final int HEADER = 0;
+ static final int TASK = 1;
+ static final int MESSAGE = 2;
+ /** "+N more" under a busy day (the message layout). */
+ static final int MORE = 3;
+ /** Distinct layouts: MORE shares MESSAGE's. */
+ static final int VIEW_TYPES = 3;
+ /** Rows per day. The whole week travels in one widget update, which has
+ * a size limit; past this a day ends in "+N more", opening the app. */
+ static final int MAX_TASKS_PER_DAY = 12;
+
+ static final class Row {
+ final int type;
+ final String iso;
+ final CalendarWidgetData.Task task;
+ /** Header: open tasks that day (including any past the snapshot's cap). */
+ final int count;
+ /** Task: the latest widget tap on it, or null. */
+ final WidgetActions.Mark mark;
+
+ Row(int type, String iso, CalendarWidgetData.Task task, int count, WidgetActions.Mark mark) {
+ this.type = type;
+ this.iso = iso;
+ this.task = task;
+ this.count = count;
+ this.mark = mark;
+ }
+ }
+
+ static List rows(CalendarWidgetData data, String selected, Map marks) {
+ if (data == null || !data.dailyEnabled) return Collections.emptyList();
+ List rows = new ArrayList<>();
+ List week = CalendarMath.weekOf(selected, data.weekStart);
+ if (!data.covers(week.get(0)) || !data.covers(week.get(6))) {
+ rows.add(new Row(MESSAGE, selected, null, 0, null));
+ return rows;
+ }
+ for (String iso : week) {
+ CalendarWidgetData.Day day = data.agenda.get(iso);
+ rows.add(new Row(HEADER, iso, null, day == null ? 0 : day.total, null));
+ if (day == null) continue;
+ int shown = Math.min(day.tasks.size(), MAX_TASKS_PER_DAY);
+ for (int i = 0; i < shown; i++) {
+ CalendarWidgetData.Task task = day.tasks.get(i);
+ rows.add(new Row(TASK, iso, task, 0, marks.get(task.id)));
+ }
+ if (day.total > shown) rows.add(new Row(MORE, iso, null, day.total - shown, null));
+ }
+ return rows;
+ }
+
+ /** Position of the selected day's header, to scroll the list to. */
+ static int positionOf(List rows, String iso) {
+ for (int i = 0; i < rows.size(); i++) {
+ if (rows.get(i).type == HEADER && rows.get(i).iso.equals(iso)) return i;
+ }
+ return 0;
+ }
+
+ static RemoteViews view(Context context, WidgetSnapshot.Palette p, Row row, String selected, String today) {
+ switch (row.type) {
+ case HEADER:
+ return header(context, p, row, selected, today);
+ case TASK:
+ return task(context, p, row);
+ case MORE:
+ return more(context, p, row);
+ default:
+ return message(context, p, row);
+ }
+ }
+
+ private static RemoteViews header(Context context, WidgetSnapshot.Palette p, Row row, String selected, String today) {
+ RemoteViews v = new RemoteViews(context.getPackageName(), R.layout.widget_cal_agenda_day);
+ v.setTextViewText(R.id.zn_agenda_day_label, CalendarMath.shortDay(row.iso).toUpperCase());
+ v.setTextColor(R.id.zn_agenda_day_label, row.iso.equals(selected) ? p.fg : p.muted);
+ v.setViewVisibility(R.id.zn_agenda_today, row.iso.equals(today) ? View.VISIBLE : View.GONE);
+ v.setInt(R.id.zn_agenda_today_bg, "setColorFilter", WidgetSnapshot.Palette.over(p.accent, 0.15f, p.bg));
+ v.setTextColor(R.id.zn_agenda_today_text, p.accent);
+ v.setTextViewText(R.id.zn_agenda_day_count, row.count > 0 ? String.valueOf(row.count) : "");
+ v.setTextColor(R.id.zn_agenda_day_count, p.muted);
+ v.setOnClickFillInIntent(R.id.zn_agenda_day, CalendarWidgetActionReceiver.selectFillIn(row.iso));
+ return v;
+ }
+
+ private static RemoteViews task(Context context, WidgetSnapshot.Palette p, Row row) {
+ CalendarWidgetData.Task task = row.task;
+ RemoteViews v = new RemoteViews(context.getPackageName(), R.layout.widget_cal_agenda_task);
+ boolean checked = row.mark != null && row.mark.checked;
+ // A tick the app has yet to apply (it could not be written here)
+ // shows in the muted color until it lands.
+ int boxColor = checked && row.mark.written ? p.accent : p.muted;
+ String content = task.content.trim();
+ v.setTextViewText(R.id.zn_task_title, content.isEmpty() ? "Untitled task" : content);
+ v.setTextColor(R.id.zn_task_title, checked ? p.muted : p.fg);
+ v.setInt(R.id.zn_task_title, "setPaintFlags",
+ Paint.ANTI_ALIAS_FLAG | (checked ? Paint.STRIKE_THRU_TEXT_FLAG : 0));
+ v.setViewVisibility(R.id.zn_task_note, task.inDailyNote ? View.GONE : View.VISIBLE);
+ v.setTextViewText(R.id.zn_task_note, task.noteTitle);
+ v.setTextColor(R.id.zn_task_note, p.muted);
+ v.setContentDescription(R.id.zn_task_check, context.getString(
+ checked ? R.string.widget_calendar_mark_not_done : R.string.widget_calendar_mark_done));
+ Intent tick = CalendarWidgetActionReceiver.toggleFillIn(task, !checked);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ v.setCompoundButtonChecked(R.id.zn_task_check, checked);
+ v.setColorStateList(R.id.zn_task_check, "setButtonTintList", ColorStateList.valueOf(boxColor));
+ v.setOnCheckedChangeResponse(R.id.zn_task_check, RemoteViews.RemoteResponse.fromFillInIntent(tick));
+ } else {
+ v.setImageViewResource(R.id.zn_task_check, checked ? R.drawable.ic_zn_check_square : R.drawable.ic_zn_square);
+ v.setInt(R.id.zn_task_check, "setColorFilter", boxColor);
+ v.setOnClickFillInIntent(R.id.zn_task_check, tick);
+ }
+ v.setOnClickFillInIntent(R.id.zn_task_open,
+ CalendarWidgetActionReceiver.openFillIn(WidgetLinks.task(task.id, task.path)));
+ return v;
+ }
+
+ private static RemoteViews more(Context context, WidgetSnapshot.Palette p, Row row) {
+ RemoteViews v = new RemoteViews(context.getPackageName(), R.layout.widget_cal_agenda_message);
+ v.setTextViewText(R.id.zn_agenda_message, context.getString(R.string.widget_calendar_more, row.count));
+ v.setTextColor(R.id.zn_agenda_message, p.muted);
+ v.setOnClickFillInIntent(R.id.zn_agenda_message,
+ CalendarWidgetActionReceiver.openFillIn(WidgetLinks.calendar(row.iso, false)));
+ return v;
+ }
+
+ private static RemoteViews message(Context context, WidgetSnapshot.Palette p, Row row) {
+ RemoteViews v = new RemoteViews(context.getPackageName(), R.layout.widget_cal_agenda_message);
+ v.setTextViewText(R.id.zn_agenda_message, context.getString(R.string.widget_calendar_out_of_range));
+ v.setTextColor(R.id.zn_agenda_message, p.muted);
+ v.setOnClickFillInIntent(R.id.zn_agenda_message,
+ CalendarWidgetActionReceiver.openFillIn(WidgetLinks.calendar(row.iso, false)));
+ return v;
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/CalendarAgendaService.java b/android/app/src/main/java/md/zennotes/widgets/CalendarAgendaService.java
new file mode 100644
index 0000000..764d184
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/CalendarAgendaService.java
@@ -0,0 +1,96 @@
+package md.zennotes.widgets;
+
+import android.appwidget.AppWidgetManager;
+import android.content.Context;
+import android.content.Intent;
+import android.widget.RemoteViews;
+import android.widget.RemoteViewsService;
+
+import java.util.Collections;
+import java.util.List;
+
+import md.zennotes.R;
+
+/**
+ * The Calendar widget's week list before Android 12, which has no
+ * collection items to hand the list over with the rest of the widget
+ * (CalendarWidgetProvider uses those from 12 on). Same rows (CalendarAgenda).
+ */
+public class CalendarAgendaService extends RemoteViewsService {
+ @Override
+ public RemoteViewsFactory onGetViewFactory(Intent intent) {
+ return new Factory(getApplicationContext(),
+ intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID));
+ }
+
+ static final class Factory implements RemoteViewsFactory {
+ private final Context context;
+ private final int widgetId;
+ private List rows = Collections.emptyList();
+ private WidgetSnapshot.Palette palette = WidgetSnapshot.Palette.fallback();
+ private String today = CalendarMath.today();
+ private String selected = today;
+
+ Factory(Context context, int widgetId) {
+ this.context = context;
+ this.widgetId = widgetId;
+ }
+
+ @Override
+ public void onCreate() {
+ load();
+ }
+
+ @Override
+ public void onDataSetChanged() {
+ load();
+ }
+
+ private void load() {
+ WidgetSnapshot snapshot = WidgetSnapshot.load(context);
+ CalendarWidgetData cal = snapshot != null ? snapshot.calendar : null;
+ palette = snapshot != null ? snapshot.palette : WidgetSnapshot.Palette.fallback();
+ today = CalendarMath.today();
+ selected = CalendarWidgetState.load(context, widgetId).selected;
+ rows = cal == null
+ ? Collections.emptyList()
+ : CalendarAgenda.rows(cal, selected, WidgetActions.marks(context, cal.vaultKey));
+ }
+
+ @Override
+ public void onDestroy() {}
+
+ @Override
+ public int getCount() {
+ return rows.size();
+ }
+
+ @Override
+ public RemoteViews getViewAt(int position) {
+ if (position < 0 || position >= rows.size()) {
+ return new RemoteViews(context.getPackageName(), R.layout.widget_cal_agenda_message);
+ }
+ return CalendarAgenda.view(context, palette, rows.get(position), selected, today);
+ }
+
+ @Override
+ public RemoteViews getLoadingView() {
+ return null;
+ }
+
+ @Override
+ public int getViewTypeCount() {
+ return CalendarAgenda.VIEW_TYPES;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/CalendarMath.java b/android/app/src/main/java/md/zennotes/widgets/CalendarMath.java
new file mode 100644
index 0000000..56d43c7
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/CalendarMath.java
@@ -0,0 +1,141 @@
+package md.zennotes.widgets;
+
+import android.text.format.DateFormat;
+
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Calendar arithmetic for the Calendar widget: the same 6-week grid, ISO
+ * week numbers and labels app-core's calendar panel draws
+ * (lib/calendar-data.ts, CalendarPanel.tsx). Dates travel as ISO
+ * `YYYY-MM-DD` strings, the form the snapshot keys days by; no task or
+ * note rules live here, only the calendar's.
+ */
+final class CalendarMath {
+ private CalendarMath() {}
+
+ /** The panel's weekday labels (it uppercases them in CSS). */
+ private static final String[] DAY_LABELS = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
+
+ static Calendar parse(String iso) {
+ Calendar c = new GregorianCalendar();
+ c.clear();
+ try {
+ c.set(Integer.parseInt(iso.substring(0, 4)), Integer.parseInt(iso.substring(5, 7)) - 1,
+ Integer.parseInt(iso.substring(8, 10)));
+ } catch (RuntimeException e) {
+ c.setTimeInMillis(System.currentTimeMillis());
+ }
+ return c;
+ }
+
+ static String iso(Calendar c) {
+ return String.format(Locale.US, "%04d-%02d-%02d",
+ c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1, c.get(Calendar.DAY_OF_MONTH));
+ }
+
+ static String today() {
+ return iso(Calendar.getInstance());
+ }
+
+ static String addDays(String iso, int days) {
+ Calendar c = parse(iso);
+ c.add(Calendar.DAY_OF_MONTH, days);
+ return iso(c);
+ }
+
+ /** `YYYY-MM` of a day. */
+ static String monthOf(String iso) {
+ return iso.substring(0, 7);
+ }
+
+ static String addMonths(String month, int months) {
+ Calendar c = parse(month + "-01");
+ c.add(Calendar.MONTH, months);
+ return iso(c).substring(0, 7);
+ }
+
+ /** 0 = Sunday … 6 = Saturday, as the snapshot's `weekStart`. */
+ static int weekday(String iso) {
+ return parse(iso).get(Calendar.DAY_OF_WEEK) - 1;
+ }
+
+ /** The 42 days of `month`'s grid starting on `weekStart` (buildGrid). */
+ static List grid(String month, int weekStart) {
+ String first = month + "-01";
+ int offset = (weekday(first) - weekStart + 7) % 7;
+ String start = addDays(first, -offset);
+ List days = new ArrayList<>(42);
+ Calendar c = parse(start);
+ for (int i = 0; i < 42; i++) {
+ days.add(iso(c));
+ c.add(Calendar.DAY_OF_MONTH, 1);
+ }
+ return days;
+ }
+
+ /** The 7 days of the week holding `iso`, from `weekStart` (the agenda). */
+ static List weekOf(String iso, int weekStart) {
+ int offset = (weekday(iso) - weekStart + 7) % 7;
+ List days = new ArrayList<>(7);
+ for (int i = 0; i < 7; i++) days.add(addDays(iso, i - offset));
+ return days;
+ }
+
+ /** The day a grid row's week number comes from: its Monday, else its
+ * first day (CalendarPanel's `monday`). */
+ static String rowMonday(List row) {
+ for (String day : row) if (weekday(day) == 1) return day;
+ return row.get(0);
+ }
+
+ private static GregorianCalendar isoCalendar(String iso) {
+ GregorianCalendar c = new GregorianCalendar();
+ c.setFirstDayOfWeek(Calendar.MONDAY);
+ c.setMinimalDaysInFirstWeek(4);
+ c.setTimeInMillis(parse(iso).getTimeInMillis());
+ return c;
+ }
+
+ static int isoWeek(String iso) {
+ return isoCalendar(iso).get(Calendar.WEEK_OF_YEAR);
+ }
+
+ /** `2026-W44`: how the snapshot keys weekly notes. */
+ static String isoWeekKey(String iso) {
+ GregorianCalendar c = isoCalendar(iso);
+ return String.format(Locale.US, "%d-W%02d", c.getWeekYear(), c.get(Calendar.WEEK_OF_YEAR));
+ }
+
+ static String dayLabel(int weekday) {
+ return DAY_LABELS[((weekday % 7) + 7) % 7];
+ }
+
+ private static String format(String iso, String skeleton) {
+ String pattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), skeleton);
+ return new SimpleDateFormat(pattern, Locale.getDefault()).format(parse(iso).getTime());
+ }
+
+ /** "October 2026". */
+ static String monthLabel(String month) {
+ return format(month + "-01", "MMMMy");
+ }
+
+ /** "27 Tue" in en-US, the panel's `{ weekday: 'short', day: 'numeric' }`. */
+ static String shortDay(String iso) {
+ return format(iso, "dEEE");
+ }
+
+ /** "Oct 26 – Nov 1", or "Oct 5 – 11" within one month (the panel's label). */
+ static String weekRange(List week) {
+ String start = week.get(0);
+ String end = week.get(6);
+ String endLabel = monthOf(start).equals(monthOf(end)) ? format(end, "d") : format(end, "MMMd");
+ return format(start, "MMMd") + " – " + endLabel;
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetActionReceiver.java b/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetActionReceiver.java
new file mode 100644
index 0000000..084c953
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetActionReceiver.java
@@ -0,0 +1,209 @@
+package md.zennotes.widgets;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.widget.RemoteViews;
+
+import org.json.JSONObject;
+
+import java.util.List;
+
+import md.zennotes.MainActivity;
+import md.zennotes.R;
+
+/**
+ * The Calendar widget's taps: month paging, picking a day, and the week
+ * list's rows (tick a task, open it, pick its day). Not exported: a tick
+ * writes into a note, so only this app's own pending intents may ask.
+ *
+ * A tick is logged first (WidgetActions, which also draws the box ticked),
+ * then written into the note when its line on disk is still the one the
+ * snapshot named (TaskLineWriter). Either way the app applies the log the
+ * next time it runs, through app-core's own toggle.
+ */
+public class CalendarWidgetActionReceiver extends BroadcastReceiver {
+ static final String ACTION_MONTH = "md.zennotes.widgets.calendar.MONTH";
+ static final String ACTION_SELECT = "md.zennotes.widgets.calendar.SELECT";
+ static final String ACTION_LIST = "md.zennotes.widgets.calendar.LIST";
+ static final String ACTION_MIDNIGHT = "md.zennotes.widgets.calendar.MIDNIGHT";
+
+ static final String EXTRA_VALUE = "md.zennotes.widgets.extra.VALUE";
+ static final String EXTRA_KIND = "md.zennotes.widgets.extra.KIND";
+ static final String EXTRA_LINK = "md.zennotes.widgets.extra.LINK";
+ static final String EXTRA_TASK_ID = "md.zennotes.widgets.extra.TASK_ID";
+ static final String EXTRA_PATH = "md.zennotes.widgets.extra.PATH";
+ static final String EXTRA_LINE = "md.zennotes.widgets.extra.LINE";
+ static final String EXTRA_RAW = "md.zennotes.widgets.extra.RAW";
+ static final String EXTRA_TOGGLED = "md.zennotes.widgets.extra.TOGGLED";
+ static final String EXTRA_WANT = "md.zennotes.widgets.extra.WANT";
+ /** RemoteViews.EXTRA_CHECKED (API 31): a CheckBox's new state, added to
+ * its fill-in intent by the system. */
+ private static final String EXTRA_CHECKED = "android.widget.extra.CHECKED";
+
+ static final String KIND_TOGGLE = "toggle";
+ static final String KIND_OPEN = "open";
+ static final String KIND_SELECT = "select";
+
+ /** Long enough for the launcher to load the new list before it scrolls. */
+ private static final long SCROLL_DELAY_MS = 350;
+
+ /** A fixed tap: its own data URI, so each cell's intent stays distinct. */
+ static PendingIntent broadcast(Context context, String action, int widgetId, String value) {
+ Intent intent = new Intent(context, CalendarWidgetActionReceiver.class).setAction(action);
+ intent.setData(Uri.parse("zennotes-widget://calendar/" + widgetId + "/" + action + "/"
+ + Uri.encode(value == null ? "" : value)));
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId);
+ intent.putExtra(EXTRA_VALUE, value);
+ return PendingIntent.getBroadcast(context, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ }
+
+ /** The week list's template; rows fill in their own extras, which needs
+ * a mutable pending intent on Android 12+. */
+ static PendingIntent listTemplate(Context context, int widgetId) {
+ Intent intent = new Intent(context, CalendarWidgetActionReceiver.class).setAction(ACTION_LIST);
+ intent.setData(Uri.parse("zennotes-widget://calendar/" + widgetId + "/list"));
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId);
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags |= PendingIntent.FLAG_MUTABLE;
+ return PendingIntent.getBroadcast(context, 0, intent, flags);
+ }
+
+ static Intent selectFillIn(String iso) {
+ return new Intent().putExtra(EXTRA_KIND, KIND_SELECT).putExtra(EXTRA_VALUE, iso);
+ }
+
+ static Intent openFillIn(Uri link) {
+ return new Intent().putExtra(EXTRA_KIND, KIND_OPEN).putExtra(EXTRA_LINK, link.toString());
+ }
+
+ static Intent toggleFillIn(CalendarWidgetData.Task task, boolean want) {
+ return new Intent()
+ .putExtra(EXTRA_KIND, KIND_TOGGLE)
+ .putExtra(EXTRA_TASK_ID, task.id)
+ .putExtra(EXTRA_PATH, task.path)
+ .putExtra(EXTRA_LINE, task.line)
+ .putExtra(EXTRA_RAW, task.raw)
+ .putExtra(EXTRA_TOGGLED, task.toggled == null ? "" : task.toggled)
+ .putExtra(EXTRA_WANT, want);
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Context app = context.getApplicationContext();
+ String action = intent.getAction();
+ int widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
+ if (ACTION_MIDNIGHT.equals(action)) {
+ CalendarWidgetProvider.refreshAll(app);
+ return;
+ }
+ if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return;
+ if (ACTION_MONTH.equals(action)) {
+ int delta;
+ try {
+ delta = Integer.parseInt(intent.getStringExtra(EXTRA_VALUE));
+ } catch (RuntimeException e) {
+ return;
+ }
+ CalendarWidgetState state = CalendarWidgetState.load(app, widgetId);
+ String month = delta == 0 ? CalendarMath.monthOf(CalendarMath.today()) : CalendarMath.addMonths(state.month, delta);
+ CalendarWidgetState.save(app, widgetId, month, state.selected);
+ redraw(app, widgetId, false);
+ } else if (ACTION_SELECT.equals(action)) {
+ select(app, widgetId, intent.getStringExtra(EXTRA_VALUE));
+ } else if (ACTION_LIST.equals(action)) {
+ String kind = intent.getStringExtra(EXTRA_KIND);
+ if (KIND_SELECT.equals(kind)) {
+ select(app, widgetId, intent.getStringExtra(EXTRA_VALUE));
+ } else if (KIND_OPEN.equals(kind)) {
+ open(app, intent.getStringExtra(EXTRA_LINK));
+ } else if (KIND_TOGGLE.equals(kind)) {
+ toggle(app, intent);
+ }
+ }
+ }
+
+ private void select(Context app, int widgetId, String iso) {
+ if (iso == null || iso.length() != 10) return;
+ CalendarWidgetState state = CalendarWidgetState.load(app, widgetId);
+ CalendarWidgetState.save(app, widgetId, state.month, iso);
+ redraw(app, widgetId, true);
+ }
+
+ private void redraw(Context app, int widgetId, boolean scrollToSelected) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(app);
+ if (manager == null) return;
+ WidgetSnapshot snapshot = WidgetSnapshot.load(app);
+ CalendarWidgetProvider.update(app, manager, widgetId, snapshot, scrollToSelected);
+ // From Android 12 the scroll rode along with the update; before, the
+ // list loads through its service and can only scroll once it has.
+ if (CalendarWidgetProvider.collectionItems()) return;
+ if (!scrollToSelected || snapshot == null || snapshot.calendar == null) return;
+ CalendarWidgetState state = CalendarWidgetState.load(app, widgetId);
+ List rows = CalendarAgenda.rows(snapshot.calendar, state.selected,
+ WidgetActions.marks(app, snapshot.calendar.vaultKey));
+ int position = CalendarAgenda.positionOf(rows, state.selected);
+ PendingResult pending = goAsync();
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
+ try {
+ RemoteViews scroll = new RemoteViews(app.getPackageName(), R.layout.widget_calendar);
+ scroll.setScrollPosition(R.id.zn_cal_agenda, position);
+ manager.partiallyUpdateAppWidget(widgetId, scroll);
+ } finally {
+ pending.finish();
+ }
+ }, SCROLL_DELAY_MS);
+ }
+
+ private static void open(Context app, String link) {
+ if (link == null || !link.startsWith("zennotes://")) return;
+ Intent view = new Intent(Intent.ACTION_VIEW, Uri.parse(link), app, MainActivity.class);
+ view.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ app.startActivity(view);
+ }
+
+ private void toggle(Context app, Intent intent) {
+ String id = intent.getStringExtra(EXTRA_TASK_ID);
+ String path = intent.getStringExtra(EXTRA_PATH);
+ String raw = intent.getStringExtra(EXTRA_RAW);
+ String toggled = intent.getStringExtra(EXTRA_TOGGLED);
+ int line = intent.getIntExtra(EXTRA_LINE, -1);
+ boolean want = intent.hasExtra(EXTRA_CHECKED)
+ ? intent.getBooleanExtra(EXTRA_CHECKED, true)
+ : intent.getBooleanExtra(EXTRA_WANT, true);
+ if (id == null || path == null || raw == null || toggled == null) return;
+ PendingResult pending = goAsync();
+ new Thread(() -> {
+ try {
+ WidgetSnapshot snapshot = WidgetSnapshot.load(app);
+ CalendarWidgetData cal = snapshot != null ? snapshot.calendar : null;
+ if (cal == null) return;
+ JSONObject action = new JSONObject()
+ .put("vaultKey", cal.vaultKey)
+ .put("id", id)
+ .put("path", path)
+ .put("line", line)
+ .put("raw", raw)
+ .put("toggled", toggled)
+ .put("checked", want)
+ .put("written", false);
+ long at = WidgetActions.append(app, action);
+ boolean written = !toggled.isEmpty() && cal.vaultRoot != null && TaskLineWriter.swap(
+ app, cal.vaultRoot, path, line, want ? raw : toggled, want ? toggled : raw);
+ if (written) WidgetActions.markWritten(app, at);
+ CalendarWidgetProvider.refreshAll(app);
+ } catch (Exception e) {
+ // The box redraws from the log; a failed log leaves it as it was.
+ } finally {
+ pending.finish();
+ }
+ }, "zn-calendar-tick").start();
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetData.java b/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetData.java
new file mode 100644
index 0000000..5b6fb3e
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetData.java
@@ -0,0 +1,165 @@
+package md.zennotes.widgets;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The snapshot's `calendar` block (src/bridge/widget-calendar.ts, built by
+ * app-core's getCalendarWidgetData): the Calendar sheet's grid marks and
+ * week agenda for the months around the day it was published. Read with a
+ * default for every field, like WidgetSnapshot.
+ */
+public final class CalendarWidgetData {
+ public final String todayIso;
+ /** 0 = Sunday … 6 = Saturday. */
+ public final int weekStart;
+ public final boolean showWeekNumbers;
+ public final boolean dailyEnabled;
+ public final boolean weeklyEnabled;
+ /** Inclusive range the marks and agenda cover. */
+ public final String from;
+ public final String to;
+ public final Map days;
+ public final Map weeks;
+ public final Map agenda;
+ public final String vaultKey;
+ /** Nullable: a remote workspace has no notes on this device. */
+ public final String vaultRoot;
+ public final long actionsAck;
+
+ private CalendarWidgetData(String todayIso, int weekStart, boolean showWeekNumbers, boolean dailyEnabled,
+ boolean weeklyEnabled, String from, String to, Map days,
+ Map weeks, Map agenda, String vaultKey,
+ String vaultRoot, long actionsAck) {
+ this.todayIso = todayIso;
+ this.weekStart = weekStart;
+ this.showWeekNumbers = showWeekNumbers;
+ this.dailyEnabled = dailyEnabled;
+ this.weeklyEnabled = weeklyEnabled;
+ this.from = from;
+ this.to = to;
+ this.days = Collections.unmodifiableMap(days);
+ this.weeks = Collections.unmodifiableMap(weeks);
+ this.agenda = Collections.unmodifiableMap(agenda);
+ this.vaultKey = vaultKey;
+ this.vaultRoot = vaultRoot;
+ this.actionsAck = actionsAck;
+ }
+
+ /** A day's (or week's) note: word-count dots and its open tasks. */
+ public static final class Mark {
+ public final int dots;
+ public final boolean faint;
+ public final int openTasks;
+
+ Mark(int dots, boolean faint, int openTasks) {
+ this.dots = dots;
+ this.faint = faint;
+ this.openTasks = openTasks;
+ }
+ }
+
+ public static final class Day {
+ public final int total;
+ public final List tasks;
+
+ Day(int total, List tasks) {
+ this.total = total;
+ this.tasks = Collections.unmodifiableList(tasks);
+ }
+ }
+
+ public static final class Task {
+ public final String id;
+ public final String path;
+ public final String noteTitle;
+ public final String content;
+ public final boolean inProgress;
+ public final boolean inDailyNote;
+ public final int line;
+ public final String raw;
+ /** Nullable: only the app can tick it (a file task). */
+ public final String toggled;
+
+ Task(String id, String path, String noteTitle, String content, boolean inProgress,
+ boolean inDailyNote, int line, String raw, String toggled) {
+ this.id = id;
+ this.path = path;
+ this.noteTitle = noteTitle;
+ this.content = content;
+ this.inProgress = inProgress;
+ this.inDailyNote = inDailyNote;
+ this.line = line;
+ this.raw = raw;
+ this.toggled = toggled;
+ }
+ }
+
+ public boolean covers(String iso) {
+ return iso.compareTo(from) >= 0 && iso.compareTo(to) <= 0;
+ }
+
+ static CalendarWidgetData parse(JSONObject c) {
+ if (c == null) return null;
+ String from = c.optString("from", "");
+ String to = c.optString("to", "");
+ if (from.length() != 10 || to.length() != 10) return null;
+ Map agenda = new HashMap<>();
+ JSONObject agendaJson = c.optJSONObject("agenda");
+ if (agendaJson != null) {
+ for (Iterator it = agendaJson.keys(); it.hasNext(); ) {
+ String iso = it.next();
+ JSONObject d = agendaJson.optJSONObject(iso);
+ if (d == null) continue;
+ List tasks = new ArrayList<>();
+ JSONArray list = d.optJSONArray("tasks");
+ if (list != null) {
+ for (int i = 0; i < list.length(); i++) {
+ JSONObject t = list.optJSONObject(i);
+ if (t == null) continue;
+ String id = t.optString("id", "");
+ String path = t.optString("path", "");
+ if (id.isEmpty() || path.isEmpty()) continue;
+ tasks.add(new Task(id, path, t.optString("noteTitle", ""), t.optString("content", ""),
+ t.optBoolean("inProgress", false), t.optBoolean("inDailyNote", false),
+ t.optInt("line", -1), t.optString("raw", ""),
+ t.isNull("toggled") ? null : t.optString("toggled", null)));
+ }
+ }
+ agenda.put(iso, new Day(d.optInt("total", tasks.size()), tasks));
+ }
+ }
+ return new CalendarWidgetData(
+ c.optString("todayIso", ""),
+ c.optInt("weekStart", 1),
+ c.optBoolean("showWeekNumbers", false),
+ c.optBoolean("dailyEnabled", false),
+ c.optBoolean("weeklyEnabled", false),
+ from, to,
+ marks(c.optJSONObject("days")),
+ marks(c.optJSONObject("weeks")),
+ agenda,
+ c.optString("vaultKey", ""),
+ c.isNull("vaultRoot") ? null : c.optString("vaultRoot", null),
+ (long) c.optDouble("actionsAck", 0));
+ }
+
+ private static Map marks(JSONObject json) {
+ Map out = new HashMap<>();
+ if (json == null) return out;
+ for (Iterator it = json.keys(); it.hasNext(); ) {
+ String key = it.next();
+ JSONObject m = json.optJSONObject(key);
+ if (m == null) continue;
+ out.put(key, new Mark(m.optInt("dots", 1), m.optBoolean("faint", true), m.optInt("openTasks", 0)));
+ }
+ return out;
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetProvider.java b/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetProvider.java
new file mode 100644
index 0000000..5f3f40f
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetProvider.java
@@ -0,0 +1,294 @@
+package md.zennotes.widgets;
+
+import android.app.AlarmManager;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.List;
+
+import md.zennotes.R;
+
+/**
+ * The Calendar widget: app-core's Calendar sheet on the Home Screen (Dave,
+ * Discord 2026-10-27). The month grid with its writing dots, open-task marks
+ * and week numbers on top; the selected day's week below, every task with a
+ * box to tick. Taps go to CalendarWidgetActionReceiver (not exported, since a
+ * tick writes into a note); this only draws.
+ *
+ * Like the panel on a touch screen: a tap selects a day and shows its week,
+ * a tap on the selected day opens its daily note. A widget cannot hold a
+ * text field, so "Add a task for …" opens the Calendar in the app on that
+ * day with the cursor in its field.
+ */
+public class CalendarWidgetProvider extends AppWidgetProvider {
+ /** Below this height the grid gives way to the week list alone. */
+ private static final int COMPACT_HEIGHT_DP = 300;
+ /** Below this width the week-number column goes. */
+ private static final int NARROW_WIDTH_DP = 250;
+
+ @Override
+ public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
+ WidgetSnapshot snapshot = WidgetSnapshot.load(context);
+ for (int id : appWidgetIds) update(context, manager, id, snapshot, false);
+ scheduleMidnight(context);
+ }
+
+ @Override
+ public void onAppWidgetOptionsChanged(Context context, AppWidgetManager manager, int appWidgetId, Bundle options) {
+ update(context, manager, appWidgetId, WidgetSnapshot.load(context), false);
+ }
+
+ /**
+ * Send one widget its whole state. From Android 12 the week list travels
+ * inside the same update as collection items: notifyAppWidgetViewDataChanged
+ * re-sends whatever views the widget service holds, and right after an
+ * update those can still be the previous ones, which put an old grid (and,
+ * after a force stop, dead tap targets) back on the Home Screen.
+ */
+ static void update(Context context, AppWidgetManager manager, int widgetId, WidgetSnapshot snapshot,
+ boolean scrollToSelected) {
+ manager.updateAppWidget(widgetId,
+ build(context, snapshot, widgetId, manager.getAppWidgetOptions(widgetId), scrollToSelected));
+ if (!collectionItems()) manager.notifyAppWidgetViewDataChanged(new int[] {widgetId}, R.id.zn_cal_agenda);
+ }
+
+ static boolean collectionItems() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S;
+ }
+
+ @Override
+ public void onDeleted(Context context, int[] appWidgetIds) {
+ for (int id : appWidgetIds) CalendarWidgetState.forget(context, id);
+ }
+
+ static int[] ids(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ if (manager == null) return new int[0];
+ int[] ids = manager.getAppWidgetIds(new ComponentName(context, CalendarWidgetProvider.class));
+ return ids == null ? new int[0] : ids;
+ }
+
+ /** Redraw every placed Calendar widget from the current snapshot. */
+ static void refreshAll(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ if (manager == null) return;
+ int[] ids = ids(context);
+ if (ids.length == 0) return;
+ WidgetSnapshot snapshot = WidgetSnapshot.load(context);
+ for (int id : ids) update(context, manager, id, snapshot, false);
+ scheduleMidnight(context);
+ }
+
+ /** Today's ring moves at midnight whether or not the app publishes. */
+ static void scheduleMidnight(Context context) {
+ AlarmManager alarms = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+ if (alarms == null) return;
+ Calendar next = Calendar.getInstance();
+ next.add(Calendar.DAY_OF_MONTH, 1);
+ next.set(Calendar.HOUR_OF_DAY, 0);
+ next.set(Calendar.MINUTE, 0);
+ next.set(Calendar.SECOND, 5);
+ alarms.set(AlarmManager.RTC, next.getTimeInMillis(),
+ CalendarWidgetActionReceiver.broadcast(context, CalendarWidgetActionReceiver.ACTION_MIDNIGHT, 0, null));
+ }
+
+ static RemoteViews build(Context context, WidgetSnapshot snapshot, int widgetId, Bundle options,
+ boolean scrollToSelected) {
+ WidgetSnapshot.Palette p = snapshot != null ? snapshot.palette : WidgetSnapshot.Palette.fallback();
+ CalendarWidgetData cal = snapshot != null ? snapshot.calendar : null;
+ CalendarWidgetState state = CalendarWidgetState.load(context, widgetId);
+ String today = CalendarMath.today();
+ int weekStart = cal != null ? cal.weekStart : 1;
+ boolean compact = options != null
+ && options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0) > 0
+ && options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0) < COMPACT_HEIGHT_DP;
+ boolean narrow = options != null
+ && options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0) > 0
+ && options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0) < NARROW_WIDTH_DP;
+ boolean daily = cal != null && cal.dailyEnabled;
+
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_calendar);
+ views.setInt(R.id.zn_widget_bg, "setColorFilter", p.bg);
+
+ // Header: ‹ month › (the label returns to the current month, as in the panel).
+ views.setTextViewText(R.id.zn_cal_month, CalendarMath.monthLabel(state.month));
+ views.setTextColor(R.id.zn_cal_month, p.fg);
+ views.setInt(R.id.zn_cal_prev, "setColorFilter", p.muted);
+ views.setInt(R.id.zn_cal_next, "setColorFilter", p.muted);
+ views.setOnClickPendingIntent(R.id.zn_cal_prev,
+ CalendarWidgetActionReceiver.broadcast(context, CalendarWidgetActionReceiver.ACTION_MONTH, widgetId, "-1"));
+ views.setOnClickPendingIntent(R.id.zn_cal_next,
+ CalendarWidgetActionReceiver.broadcast(context, CalendarWidgetActionReceiver.ACTION_MONTH, widgetId, "1"));
+ views.setOnClickPendingIntent(R.id.zn_cal_month,
+ CalendarWidgetActionReceiver.broadcast(context, CalendarWidgetActionReceiver.ACTION_MONTH, widgetId, "0"));
+ views.setViewVisibility(R.id.zn_cal_header, compact ? View.GONE : View.VISIBLE);
+ views.setViewVisibility(R.id.zn_cal_divider, compact ? View.GONE : View.VISIBLE);
+ views.setInt(R.id.zn_cal_divider, "setColorFilter", p.bg2);
+
+ views.removeAllViews(R.id.zn_cal_grid);
+ views.setViewVisibility(R.id.zn_cal_grid, compact ? View.GONE : View.VISIBLE);
+ if (!compact) addGrid(context, views, p, cal, state, today, weekStart, widgetId,
+ cal != null && cal.showWeekNumbers && !narrow);
+
+ // The week bar: "Week of …", Day → / Day +, Week → when weekly notes are on.
+ List week = CalendarMath.weekOf(state.selected, weekStart);
+ views.setViewVisibility(R.id.zn_cal_week_bar, daily ? View.VISIBLE : View.GONE);
+ views.setViewVisibility(R.id.zn_cal_add, daily ? View.VISIBLE : View.GONE);
+ if (daily) {
+ views.setTextViewText(R.id.zn_cal_week_label,
+ context.getString(R.string.widget_calendar_week_of, CalendarMath.weekRange(week)));
+ views.setTextColor(R.id.zn_cal_week_label, p.fg);
+ boolean hasDay = cal.days.containsKey(state.selected);
+ views.setTextViewText(R.id.zn_cal_day_link, context.getString(
+ hasDay ? R.string.widget_calendar_day_open : R.string.widget_calendar_day_create));
+ views.setTextColor(R.id.zn_cal_day_link, p.muted);
+ views.setOnClickPendingIntent(R.id.zn_cal_day_link,
+ WidgetLinks.activity(context, WidgetLinks.day(state.selected), 0));
+ views.setViewVisibility(R.id.zn_cal_week_link, cal.weeklyEnabled ? View.VISIBLE : View.GONE);
+ if (cal.weeklyEnabled) {
+ boolean hasWeek = cal.weeks.containsKey(CalendarMath.isoWeekKey(state.selected));
+ views.setTextViewText(R.id.zn_cal_week_link, context.getString(
+ hasWeek ? R.string.widget_calendar_week_open : R.string.widget_calendar_week_create));
+ views.setTextColor(R.id.zn_cal_week_link, p.muted);
+ views.setOnClickPendingIntent(R.id.zn_cal_week_link,
+ WidgetLinks.activity(context, WidgetLinks.week(state.selected), 0));
+ }
+ views.setInt(R.id.zn_cal_add_bg, "setColorFilter", WidgetSnapshot.Palette.over(p.bg2, 0.6f, p.bg));
+ views.setTextViewText(R.id.zn_cal_add_text,
+ context.getString(R.string.widget_calendar_add_task, CalendarMath.shortDay(state.selected)));
+ views.setTextColor(R.id.zn_cal_add_text, p.muted);
+ views.setOnClickPendingIntent(R.id.zn_cal_add,
+ WidgetLinks.activity(context, WidgetLinks.calendar(state.selected, true), 0));
+ }
+
+ // The week list, every row's tap through one broadcast template (a
+ // collection allows nothing else).
+ if (collectionItems()) {
+ List rows = cal == null ? Collections.emptyList()
+ : CalendarAgenda.rows(cal, state.selected, WidgetActions.marks(context, cal.vaultKey));
+ RemoteViews.RemoteCollectionItems.Builder items = new RemoteViews.RemoteCollectionItems.Builder()
+ .setViewTypeCount(CalendarAgenda.VIEW_TYPES)
+ .setHasStableIds(false);
+ for (int i = 0; i < rows.size(); i++) {
+ items.addItem(i, CalendarAgenda.view(context, p, rows.get(i), state.selected, today));
+ }
+ views.setRemoteAdapter(R.id.zn_cal_agenda, items.build());
+ if (scrollToSelected) {
+ views.setScrollPosition(R.id.zn_cal_agenda, CalendarAgenda.positionOf(rows, state.selected));
+ }
+ } else {
+ Intent adapter = new Intent(context, CalendarAgendaService.class);
+ adapter.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId);
+ adapter.setData(Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)));
+ views.setRemoteAdapter(R.id.zn_cal_agenda, adapter);
+ }
+ views.setEmptyView(R.id.zn_cal_agenda, R.id.zn_cal_empty);
+ views.setPendingIntentTemplate(R.id.zn_cal_agenda, CalendarWidgetActionReceiver.listTemplate(context, widgetId));
+ if (cal == null) {
+ views.setTextViewText(R.id.zn_cal_empty_title, context.getString(R.string.widget_empty_open_app));
+ views.setTextViewText(R.id.zn_cal_empty_detail, context.getString(R.string.widget_calendar_empty_detail));
+ } else {
+ views.setTextViewText(R.id.zn_cal_empty_title, context.getString(R.string.widget_calendar_daily_off));
+ views.setTextViewText(R.id.zn_cal_empty_detail, context.getString(R.string.widget_calendar_daily_off_detail));
+ }
+ views.setTextColor(R.id.zn_cal_empty_title, p.fg);
+ views.setTextColor(R.id.zn_cal_empty_detail, p.muted);
+ views.setOnClickPendingIntent(R.id.zn_cal_empty,
+ WidgetLinks.activity(context, WidgetLinks.calendar(null, false), 0));
+ return views;
+ }
+
+ private static void addGrid(Context context, RemoteViews views, WidgetSnapshot.Palette p, CalendarWidgetData cal,
+ CalendarWidgetState state, String today, int weekStart, int widgetId,
+ boolean weekNumbers) {
+ String pkg = context.getPackageName();
+ RemoteViews labels = new RemoteViews(pkg, R.layout.widget_cal_row);
+ if (weekNumbers) {
+ RemoteViews w = new RemoteViews(pkg, R.layout.widget_cal_week_label);
+ w.setTextColor(R.id.zn_cal_label, p.muted);
+ labels.addView(R.id.zn_cal_row, w);
+ }
+ for (int i = 0; i < 7; i++) {
+ RemoteViews label = new RemoteViews(pkg, R.layout.widget_cal_label);
+ label.setTextViewText(R.id.zn_cal_label, CalendarMath.dayLabel(weekStart + i));
+ label.setTextColor(R.id.zn_cal_label, p.muted);
+ labels.addView(R.id.zn_cal_row, label);
+ }
+ views.addView(R.id.zn_cal_grid, labels);
+
+ List days = CalendarMath.grid(state.month, weekStart);
+ // The panel's faint ink for days outside the month and week numbers:
+ // the theme's muted color is nearly as bright as its text.
+ int faint = WidgetSnapshot.Palette.over(p.muted, 0.6f, p.bg);
+ boolean daily = cal != null && cal.dailyEnabled;
+ for (int r = 0; r < 6; r++) {
+ List row = days.subList(r * 7, r * 7 + 7);
+ RemoteViews rowViews = new RemoteViews(pkg, R.layout.widget_cal_row);
+ if (weekNumbers) {
+ String monday = CalendarMath.rowMonday(row);
+ RemoteViews cell = new RemoteViews(pkg, R.layout.widget_cal_week);
+ CalendarWidgetData.Mark mark = cal != null && cal.weeklyEnabled
+ ? cal.weeks.get(CalendarMath.isoWeekKey(monday)) : null;
+ paintCell(cell, p, String.valueOf(CalendarMath.isoWeek(monday)), faint, false, false, mark);
+ if (cal != null && cal.weeklyEnabled) {
+ cell.setOnClickPendingIntent(R.id.zn_cal_cell, WidgetLinks.activity(context, WidgetLinks.week(monday), 0));
+ }
+ rowViews.addView(R.id.zn_cal_row, cell);
+ }
+ for (String iso : row) {
+ boolean inMonth = CalendarMath.monthOf(iso).equals(state.month);
+ boolean selected = daily && iso.equals(state.selected);
+ boolean isToday = iso.equals(today);
+ int color = selected ? p.fg : isToday ? p.accent : inMonth ? p.fg : faint;
+ RemoteViews cell = new RemoteViews(pkg, R.layout.widget_cal_day);
+ paintCell(cell, p, String.valueOf(Integer.parseInt(iso.substring(8))), color, selected, isToday,
+ cal != null ? cal.days.get(iso) : null);
+ if (cal == null) {
+ cell.setOnClickPendingIntent(R.id.zn_cal_cell,
+ WidgetLinks.activity(context, WidgetLinks.calendar(null, false), 0));
+ } else if (daily) {
+ cell.setOnClickPendingIntent(R.id.zn_cal_cell, selected
+ ? WidgetLinks.activity(context, WidgetLinks.day(iso), 0)
+ : CalendarWidgetActionReceiver.broadcast(context,
+ CalendarWidgetActionReceiver.ACTION_SELECT, widgetId, iso));
+ }
+ rowViews.addView(R.id.zn_cal_row, cell);
+ }
+ views.addView(R.id.zn_cal_grid, rowViews);
+ }
+ }
+
+ /** One cell, as CalendarPanel draws it: selected = fill + ring, today =
+ * ring, dots for the note's words, the corner mark for its open tasks. */
+ private static void paintCell(RemoteViews cell, WidgetSnapshot.Palette p, String text, int color,
+ boolean selected, boolean today, CalendarWidgetData.Mark mark) {
+ cell.setTextViewText(R.id.zn_cal_cell_num, text);
+ cell.setTextColor(R.id.zn_cal_cell_num, color);
+ cell.setViewVisibility(R.id.zn_cal_cell_fill, selected ? View.VISIBLE : View.GONE);
+ cell.setInt(R.id.zn_cal_cell_fill, "setColorFilter", p.bg2);
+ cell.setViewVisibility(R.id.zn_cal_cell_ring, selected || today ? View.VISIBLE : View.GONE);
+ cell.setInt(R.id.zn_cal_cell_ring, "setColorFilter",
+ WidgetSnapshot.Palette.over(p.accent, selected ? 0.6f : 0.5f, selected ? p.bg2 : p.bg));
+ if (mark != null) {
+ StringBuilder dots = new StringBuilder();
+ for (int i = 0; i < Math.max(1, Math.min(3, mark.dots)); i++) dots.append('•');
+ cell.setTextViewText(R.id.zn_cal_cell_dots, dots.toString());
+ cell.setTextColor(R.id.zn_cal_cell_dots, WidgetSnapshot.Palette.withAlpha(p.muted, mark.faint ? 0.4f : 1f));
+ } else {
+ cell.setTextViewText(R.id.zn_cal_cell_dots, "");
+ }
+ boolean marked = mark != null && mark.openTasks > 0;
+ cell.setViewVisibility(R.id.zn_cal_cell_mark, marked ? View.VISIBLE : View.GONE);
+ cell.setInt(R.id.zn_cal_cell_mark, "setColorFilter", p.accent);
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetState.java b/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetState.java
new file mode 100644
index 0000000..ed56459
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/CalendarWidgetState.java
@@ -0,0 +1,55 @@
+package md.zennotes.widgets;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+/**
+ * What one placed Calendar widget shows: the month in its grid and the
+ * selected day (whose week the agenda lists), like the panel's `anchor`
+ * and `selectedIso`. A new day starts over on today, the way the panel
+ * orients on today when it opens.
+ */
+final class CalendarWidgetState {
+ private static final String PREFS = "zn_calendar_widget";
+
+ /** `YYYY-MM`. */
+ final String month;
+ /** `YYYY-MM-DD`. */
+ final String selected;
+
+ private CalendarWidgetState(String month, String selected) {
+ this.month = month;
+ this.selected = selected;
+ }
+
+ private static SharedPreferences prefs(Context context) {
+ return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+
+ static CalendarWidgetState load(Context context, int widgetId) {
+ String today = CalendarMath.today();
+ SharedPreferences p = prefs(context);
+ if (!today.equals(p.getString("on_" + widgetId, ""))) {
+ return new CalendarWidgetState(CalendarMath.monthOf(today), today);
+ }
+ String month = p.getString("month_" + widgetId, CalendarMath.monthOf(today));
+ String selected = p.getString("sel_" + widgetId, today);
+ return new CalendarWidgetState(month, selected);
+ }
+
+ static void save(Context context, int widgetId, String month, String selected) {
+ prefs(context).edit()
+ .putString("on_" + widgetId, CalendarMath.today())
+ .putString("month_" + widgetId, month)
+ .putString("sel_" + widgetId, selected)
+ .apply();
+ }
+
+ static void forget(Context context, int widgetId) {
+ prefs(context).edit()
+ .remove("on_" + widgetId)
+ .remove("month_" + widgetId)
+ .remove("sel_" + widgetId)
+ .apply();
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/TaskLineWriter.java b/android/app/src/main/java/md/zennotes/widgets/TaskLineWriter.java
new file mode 100644
index 0000000..578eb20
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/TaskLineWriter.java
@@ -0,0 +1,216 @@
+package md.zennotes.widgets;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.Document;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Writes a Calendar widget tick into the note itself, with the app closed.
+ *
+ * It never parses a task: app-core sent both forms of the line (`raw`
+ * unticked, `toggled` ticked, from its own toggleTaskAtIndex), and this
+ * swaps one for the other only when the line at that number is exactly the
+ * one expected. Anything else (the note moved on, a different line there,
+ * a folder it cannot reach) answers false and the tap waits in the log for
+ * the app, which finds the task again or leaves it alone.
+ *
+ * Line numbers count the parser's lines: core splits on "\n" after turning
+ * "\r\n" into "\n", so a CRLF line compares without its "\r" and keeps it.
+ * Notes are overwritten in place, as the vault writes them: a temporary
+ * file beside the note would show up in the app's listings.
+ */
+final class TaskLineWriter {
+ private TaskLineWriter() {}
+
+ private static final Object LOCK = new Object();
+ private static final long MAX_BYTES = 8L * 1024 * 1024;
+
+ /** The line now holds `wanted` (written, or it already did). */
+ static boolean swap(Context context, String root, String relPath, int line, String expected, String wanted) {
+ if (root == null || relPath == null || line < 0 || expected == null || wanted == null) return false;
+ if (!safe(relPath)) return false;
+ synchronized (LOCK) {
+ try {
+ Target target = Target.resolve(context, root, relPath);
+ if (target == null) return false;
+ String text = target.read();
+ if (text == null) return false;
+ int start = 0;
+ for (int i = 0; i < line; i++) {
+ int next = text.indexOf('\n', start);
+ if (next < 0) return false;
+ start = next + 1;
+ }
+ int end = text.indexOf('\n', start);
+ if (end < 0) end = text.length();
+ String current = text.substring(start, end);
+ String ending = "";
+ if (current.endsWith("\r")) {
+ current = current.substring(0, current.length() - 1);
+ ending = "\r";
+ }
+ String bom = "";
+ if (line == 0 && current.startsWith("\uFEFF")) {
+ bom = "\uFEFF";
+ current = current.substring(1);
+ }
+ if (current.equals(wanted)) return true;
+ if (!current.equals(expected)) return false;
+ target.write(text.substring(0, start) + bom + wanted + ending + text.substring(end));
+ return true;
+ } catch (IOException | RuntimeException e) {
+ return false;
+ }
+ }
+ }
+
+ /** Vault-relative, forward slashes, no `..` segment (SafFsPlugin.clean's rule). */
+ static boolean safe(String rel) {
+ if (rel.isEmpty() || rel.startsWith("/") || rel.contains("\\") || rel.indexOf('\0') >= 0) return false;
+ for (String segment : rel.split("/", -1)) {
+ if (segment.isEmpty() || segment.equals(".") || segment.equals("..")) return false;
+ }
+ return true;
+ }
+
+ private abstract static class Target {
+ abstract InputStream open() throws IOException;
+
+ abstract OutputStream create() throws IOException;
+
+ String read() throws IOException {
+ try (InputStream in = open()) {
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ byte[] chunk = new byte[8192];
+ int n;
+ long total = 0;
+ while ((n = in.read(chunk)) != -1) {
+ total += n;
+ if (total > MAX_BYTES) return null;
+ buffer.write(chunk, 0, n);
+ }
+ // A note that is not valid UTF-8 would come back with every bad
+ // byte replaced; leave it for the app rather than rewrite it so.
+ try {
+ return StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(buffer.toByteArray()))
+ .toString();
+ } catch (CharacterCodingException e) {
+ return null;
+ }
+ }
+ }
+
+ void write(String text) throws IOException {
+ try (OutputStream out = create()) {
+ out.write(text.getBytes(StandardCharsets.UTF_8));
+ if (out instanceof FileOutputStream) ((FileOutputStream) out).getFD().sync();
+ }
+ }
+
+ static Target resolve(Context context, String root, String rel) {
+ if (root.startsWith("file:")) {
+ String base = Uri.parse(root).getPath();
+ if (base == null) return null;
+ File file = new File(base, rel);
+ return file.isFile() ? new FileTarget(file) : null;
+ }
+ if (root.startsWith("content:")) {
+ ContentResolver resolver = context.getContentResolver();
+ Uri tree = Uri.parse(root);
+ Uri doc = findDocument(resolver, tree, rel);
+ return doc == null ? null : new DocumentTarget(resolver, doc);
+ }
+ return null;
+ }
+
+ /** Walk a picked folder by name, one listing per segment (a
+ * provider's document ids are opaque, so no id is built by hand). */
+ private static Uri findDocument(ContentResolver resolver, Uri tree, String rel) {
+ String docId = DocumentsContract.getTreeDocumentId(tree);
+ String[] segments = rel.split("/");
+ for (int s = 0; s < segments.length; s++) {
+ Uri children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, docId);
+ String found = null;
+ boolean dir = false;
+ try (Cursor c = resolver.query(children, new String[] {
+ Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE
+ }, null, null, null)) {
+ if (c == null) return null;
+ while (c.moveToNext()) {
+ if (segments[s].equals(c.getString(1))) {
+ found = c.getString(0);
+ dir = Document.MIME_TYPE_DIR.equals(c.getString(2));
+ break;
+ }
+ }
+ }
+ if (found == null) return null;
+ boolean last = s == segments.length - 1;
+ if (last == dir) return null;
+ docId = found;
+ }
+ return DocumentsContract.buildDocumentUriUsingTree(tree, docId);
+ }
+ }
+
+ private static final class FileTarget extends Target {
+ private final File file;
+
+ FileTarget(File file) {
+ this.file = file;
+ }
+
+ @Override
+ InputStream open() throws IOException {
+ return new FileInputStream(file);
+ }
+
+ @Override
+ OutputStream create() throws IOException {
+ return new FileOutputStream(file, false);
+ }
+ }
+
+ private static final class DocumentTarget extends Target {
+ private final ContentResolver resolver;
+ private final Uri doc;
+
+ DocumentTarget(ContentResolver resolver, Uri doc) {
+ this.resolver = resolver;
+ this.doc = doc;
+ }
+
+ @Override
+ InputStream open() throws IOException {
+ InputStream in = resolver.openInputStream(doc);
+ if (in == null) throw new IOException("No stream for " + doc);
+ return in;
+ }
+
+ @Override
+ OutputStream create() throws IOException {
+ // "rwt" truncates; plain "w" can leave a stale tail (SafFsPlugin).
+ OutputStream out = resolver.openOutputStream(doc, "rwt");
+ if (out == null) throw new IOException("No stream for " + doc);
+ return out;
+ }
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/WidgetActions.java b/android/app/src/main/java/md/zennotes/widgets/WidgetActions.java
new file mode 100644
index 0000000..a120aef
--- /dev/null
+++ b/android/app/src/main/java/md/zennotes/widgets/WidgetActions.java
@@ -0,0 +1,126 @@
+package md.zennotes.widgets;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * The Calendar widget's checkbox taps (src/bridge/widget-calendar.ts
+ * `WidgetAction`), logged until the app has applied them. The log is also
+ * what draws a tapped row ticked: a tap must show at once, and keep showing
+ * until a snapshot built after the app saw it arrives, or a publish that
+ * raced the tap would put the box back. A snapshot's `actionsAck` (the
+ * newest tap the app applied) is what prunes it.
+ *
+ * The widget's receiver and WidgetBridgePlugin run in the app's one
+ * process, so a lock around SharedPreferences is all the coordination the
+ * log needs.
+ */
+public final class WidgetActions {
+ private WidgetActions() {}
+
+ private static final String PREFS = "zn_widget_actions";
+ private static final String KEY = "log";
+ /** The newest `at` ever handed out. Kept beside the log because the log
+ * can be pruned empty: a clock set back after that would otherwise give
+ * a new tap an `at` the app has already acked, and it would be pruned
+ * unapplied. */
+ private static final String LAST_AT = "lastAt";
+ /** A tap the app never got to (uninstalled vault, a long absence) must
+ * not grow the log forever. */
+ private static final int MAX_ACTIONS = 200;
+
+ /** The latest tap for one task, as the widget draws it. */
+ static final class Mark {
+ final boolean checked;
+ final boolean written;
+
+ Mark(boolean checked, boolean written) {
+ this.checked = checked;
+ this.written = written;
+ }
+ }
+
+ private static SharedPreferences prefs(Context context) {
+ return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+
+ private static JSONArray read(Context context) {
+ try {
+ return new JSONArray(prefs(context).getString(KEY, "[]"));
+ } catch (JSONException e) {
+ return new JSONArray();
+ }
+ }
+
+ private static void save(Context context, JSONArray log) {
+ prefs(context).edit().putString(KEY, log.toString()).commit();
+ }
+
+ /** Append a tap; returns its `at`, above every earlier one. */
+ static synchronized long append(Context context, JSONObject action) throws JSONException {
+ JSONArray log = read(context);
+ long at = System.currentTimeMillis();
+ long floor = prefs(context).getLong(LAST_AT, 0);
+ if (log.length() > 0) {
+ floor = Math.max(floor, (long) log.getJSONObject(log.length() - 1).optDouble("at", 0));
+ }
+ if (at <= floor) at = floor + 1;
+ action.put("at", at);
+ log.put(action);
+ while (log.length() > MAX_ACTIONS) log.remove(0);
+ prefs(context).edit().putString(KEY, log.toString()).putLong(LAST_AT, at).commit();
+ return at;
+ }
+
+ static synchronized void markWritten(Context context, long at) {
+ JSONArray log = read(context);
+ for (int i = 0; i < log.length(); i++) {
+ JSONObject a = log.optJSONObject(i);
+ if (a != null && (long) a.optDouble("at", 0) == at) {
+ try {
+ a.put("written", true);
+ } catch (JSONException ignored) {
+ return;
+ }
+ save(context, log);
+ return;
+ }
+ }
+ }
+
+ /** The log as JSON, for WidgetBridgePlugin.readActions. */
+ public static synchronized String json(Context context) {
+ return read(context).toString();
+ }
+
+ /** Drop every tap the app has applied (`at <= ack`). */
+ public static synchronized void prune(Context context, long ack) {
+ if (ack <= 0) return;
+ JSONArray log = read(context);
+ JSONArray kept = new JSONArray();
+ for (int i = 0; i < log.length(); i++) {
+ JSONObject a = log.optJSONObject(i);
+ if (a != null && (long) a.optDouble("at", 0) > ack) kept.put(a);
+ }
+ if (kept.length() != log.length()) save(context, kept);
+ }
+
+ /** Task id -> its latest tap in `vaultKey`, oldest to newest so the last wins. */
+ static synchronized Map marks(Context context, String vaultKey) {
+ Map out = new HashMap<>();
+ JSONArray log = read(context);
+ for (int i = 0; i < log.length(); i++) {
+ JSONObject a = log.optJSONObject(i);
+ if (a == null || !vaultKey.equals(a.optString("vaultKey", ""))) continue;
+ out.put(a.optString("id", ""), new Mark(a.optBoolean("checked", false), a.optBoolean("written", false)));
+ }
+ return out;
+ }
+}
diff --git a/android/app/src/main/java/md/zennotes/widgets/WidgetLinks.java b/android/app/src/main/java/md/zennotes/widgets/WidgetLinks.java
index 835728e..7fe85cc 100644
--- a/android/app/src/main/java/md/zennotes/widgets/WidgetLinks.java
+++ b/android/app/src/main/java/md/zennotes/widgets/WidgetLinks.java
@@ -46,6 +46,22 @@ public static Uri task(String id, String path) {
return Uri.parse("zennotes://task?id=" + encode(id) + "&path=" + encode(path));
}
+ /** The Calendar sheet on a day; `add` puts the cursor in "Add a task". */
+ public static Uri calendar(String iso, boolean add) {
+ if (iso == null) return Uri.parse("zennotes://calendar");
+ return Uri.parse("zennotes://calendar?date=" + encode(iso) + (add ? "&add=1" : ""));
+ }
+
+ /** The day's daily note, or the Calendar on that day when it has none. */
+ public static Uri day(String iso) {
+ return Uri.parse("zennotes://day?date=" + encode(iso));
+ }
+
+ /** The day's weekly note, or the Calendar on that day when it has none. */
+ public static Uri week(String iso) {
+ return Uri.parse("zennotes://week?date=" + encode(iso));
+ }
+
static String encode(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
StringBuilder out = new StringBuilder(bytes.length * 3);
diff --git a/android/app/src/main/java/md/zennotes/widgets/WidgetSnapshot.java b/android/app/src/main/java/md/zennotes/widgets/WidgetSnapshot.java
index 56fe428..9f9f6e8 100644
--- a/android/app/src/main/java/md/zennotes/widgets/WidgetSnapshot.java
+++ b/android/app/src/main/java/md/zennotes/widgets/WidgetSnapshot.java
@@ -38,9 +38,12 @@ public final class WidgetSnapshot {
public final int todayCount;
public final int overdueCount;
public final boolean tasksReady;
+ /** Nullable: the Calendar widget's block (CalendarWidgetData). */
+ public final CalendarWidgetData calendar;
private WidgetSnapshot(long generatedAt, String vaultName, Palette palette, List notes,
- List tasks, int todayCount, int overdueCount, boolean tasksReady) {
+ List tasks, int todayCount, int overdueCount, boolean tasksReady,
+ CalendarWidgetData calendar) {
this.generatedAt = generatedAt;
this.vaultName = vaultName;
this.palette = palette;
@@ -49,6 +52,7 @@ private WidgetSnapshot(long generatedAt, String vaultName, Palette palette, List
this.todayCount = todayCount;
this.overdueCount = overdueCount;
this.tasksReady = tasksReady;
+ this.calendar = calendar;
}
public static final class Note {
@@ -154,6 +158,17 @@ static int hex(JSONObject theme, String key, int fallback) {
}
}
+ /** `color` laid over `base` at `alpha` (0..1), opaque. A tint must be
+ * opaque: setColorFilter's SRC_ATOP keeps a translucent tint's white
+ * drawable showing through, which reads as light grey on a dark theme. */
+ public static int over(int color, float alpha, int base) {
+ float a = Math.max(0f, Math.min(1f, alpha));
+ int r = Math.round(((color >> 16) & 0xFF) * a + ((base >> 16) & 0xFF) * (1 - a));
+ int g = Math.round(((color >> 8) & 0xFF) * a + ((base >> 8) & 0xFF) * (1 - a));
+ int b = Math.round((color & 0xFF) * a + (base & 0xFF) * (1 - a));
+ return 0xFF000000 | (r << 16) | (g << 8) | b;
+ }
+
/** The color with its alpha replaced (0..1), for tinted circles behind glyphs. */
public static int withAlpha(int color, float alpha) {
int a = Math.max(0, Math.min(255, Math.round(alpha * 255)));
@@ -244,6 +259,6 @@ static WidgetSnapshot parse(String json) throws JSONException {
int todayCount = counts != null ? counts.optInt("today", tasks.size()) : tasks.size();
int overdueCount = counts != null ? counts.optInt("overdue", 0) : 0;
return new WidgetSnapshot(generatedAt, vaultName, palette, notes, tasks, todayCount, overdueCount,
- root.optBoolean("tasksReady", false));
+ root.optBoolean("tasksReady", false), CalendarWidgetData.parse(root.optJSONObject("calendar")));
}
}
diff --git a/android/app/src/main/java/md/zennotes/widgets/WidgetUpdater.java b/android/app/src/main/java/md/zennotes/widgets/WidgetUpdater.java
index dbdb244..f3b72fc 100644
--- a/android/app/src/main/java/md/zennotes/widgets/WidgetUpdater.java
+++ b/android/app/src/main/java/md/zennotes/widgets/WidgetUpdater.java
@@ -37,6 +37,8 @@ public static void refreshAll(Context context) {
manager.updateAppWidget(id, TasksWidgetProvider.build(app, snapshot, id));
}
if (tasks.length > 0) manager.notifyAppWidgetViewDataChanged(tasks, R.id.zn_widget_list);
+
+ CalendarWidgetProvider.refreshAll(app);
}
private static int[] ids(Context context, AppWidgetManager manager,
diff --git a/android/app/src/main/res/drawable/ic_zn_chevron_left.xml b/android/app/src/main/res/drawable/ic_zn_chevron_left.xml
new file mode 100644
index 0000000..2f8d112
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_zn_chevron_left.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_zn_chevron_right.xml b/android/app/src/main/res/drawable/ic_zn_chevron_right.xml
new file mode 100644
index 0000000..24b2dd1
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_zn_chevron_right.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/zn_cal_cell.xml b/android/app/src/main/res/drawable/zn_cal_cell.xml
new file mode 100644
index 0000000..e7ebf2b
--- /dev/null
+++ b/android/app/src/main/res/drawable/zn_cal_cell.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/zn_cal_ring.xml b/android/app/src/main/res/drawable/zn_cal_ring.xml
new file mode 100644
index 0000000..1732238
--- /dev/null
+++ b/android/app/src/main/res/drawable/zn_cal_ring.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/zn_line.xml b/android/app/src/main/res/drawable/zn_line.xml
new file mode 100644
index 0000000..4d510e8
--- /dev/null
+++ b/android/app/src/main/res/drawable/zn_line.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/android/app/src/main/res/layout-v31/widget_cal_agenda_task.xml b/android/app/src/main/res/layout-v31/widget_cal_agenda_task.xml
new file mode 100644
index 0000000..2f35560
--- /dev/null
+++ b/android/app/src/main/res/layout-v31/widget_cal_agenda_task.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_cal_agenda_day.xml b/android/app/src/main/res/layout/widget_cal_agenda_day.xml
new file mode 100644
index 0000000..8ee09c3
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_cal_agenda_day.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_cal_agenda_message.xml b/android/app/src/main/res/layout/widget_cal_agenda_message.xml
new file mode 100644
index 0000000..c8c268e
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_cal_agenda_message.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/android/app/src/main/res/layout/widget_cal_agenda_task.xml b/android/app/src/main/res/layout/widget_cal_agenda_task.xml
new file mode 100644
index 0000000..3920ea9
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_cal_agenda_task.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_cal_day.xml b/android/app/src/main/res/layout/widget_cal_day.xml
new file mode 100644
index 0000000..571e5f9
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_cal_day.xml
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_cal_label.xml b/android/app/src/main/res/layout/widget_cal_label.xml
new file mode 100644
index 0000000..3d34ac1
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_cal_label.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/android/app/src/main/res/layout/widget_cal_row.xml b/android/app/src/main/res/layout/widget_cal_row.xml
new file mode 100644
index 0000000..900b33e
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_cal_row.xml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/android/app/src/main/res/layout/widget_cal_week.xml b/android/app/src/main/res/layout/widget_cal_week.xml
new file mode 100644
index 0000000..1f316fe
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_cal_week.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_cal_week_label.xml b/android/app/src/main/res/layout/widget_cal_week_label.xml
new file mode 100644
index 0000000..4b35c47
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_cal_week_label.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/android/app/src/main/res/layout/widget_calendar.xml b/android/app/src/main/res/layout/widget_calendar.xml
new file mode 100644
index 0000000..51495f2
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_calendar.xml
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_calendar_preview.xml b/android/app/src/main/res/layout/widget_calendar_preview.xml
new file mode 100644
index 0000000..b91d869
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_calendar_preview.xml
@@ -0,0 +1,345 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml
index 8dc0f24..8f1f3d0 100644
--- a/android/app/src/main/res/values/dimens.xml
+++ b/android/app/src/main/res/values/dimens.xml
@@ -2,4 +2,6 @@
16dp
+
+ 34dp
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index bbe4d2c..ad1d203 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -25,6 +25,23 @@
Your tasks show up after the first scan.
All clear
Nothing due today.
+ Calendar
+ The month, with each week\'s tasks to tick off.
+ Previous month
+ Next month
+ Mark done
+ Mark not done
+ Week of %1$s
+ Add a task for %1$s\u2026
+ Day \u2192
+ Day +
+ Week \u2192
+ Week +
+ +%1$d more
+ Open ZenNotes to see this week.
+ Daily notes are off
+ Turn them on in ZenNotes to list tasks by day.
+ The calendar fills in from your vault.
My Vault
Reading list
@@ -40,4 +57,9 @@
Product ideas
Reply to the design review
Today
+ October 2026
+ Week of Oct 26 \u2013 Nov 1
+ Pick up the dry cleaning
+ Book the October flights
+ Trip planning
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
index bd0c4d8..c316b1b 100644
--- a/android/app/src/main/res/xml/file_paths.xml
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -2,4 +2,8 @@
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/widget_calendar_info.xml b/android/app/src/main/res/xml/widget_calendar_info.xml
new file mode 100644
index 0000000..884847a
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_calendar_info.xml
@@ -0,0 +1,16 @@
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 6c6214b..3290d25 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "zennotes-android",
- "version": "1.1.47",
+ "version": "1.1.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zennotes-android",
- "version": "1.1.47",
+ "version": "1.1.48",
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.1",
"@capacitor/android": "^8.5.3",
@@ -32,9 +32,9 @@
"@lezer/highlight": "^1.2.5",
"@replit/codemirror-vim": "^6.3.0",
"@xyflow/react": "^12.12.0",
- "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz",
- "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz",
- "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz",
+ "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz",
+ "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz",
+ "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz",
"codemirror": "^6.0.1",
"dompurify": "^3.4.17",
"function-plot": "^1.26.0",
@@ -3668,9 +3668,9 @@
}
},
"node_modules/@zennotes/app-core": {
- "version": "2.77.0-core.hfe38f5ee2a70bd64",
- "resolved": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz",
- "integrity": "sha512-wZNZS/WkLLqi9DzWlKo5FYPl6Dpkk+C/viiiu7rhsryZARIrQodj5LDa9gTx3TUsIiDKFSg5XoP73ShmLuYsVA==",
+ "version": "2.78.0-core.h0bc32cbd73a78fac",
+ "resolved": "file:vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz",
+ "integrity": "sha512-Hq5qAJFFBJrJd67PxazwxgVPTZQ6K6bXkU9FoUILCywi4JcGR/hy60Klb5rClsO/Q+E8a/l6IbkE+T9TsUK4YA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
@@ -3700,8 +3700,8 @@
"@replit/codemirror-vim": "^6.3.0",
"@terrastruct/d2": "0.1.33",
"@xyflow/react": "^12.11.2",
- "@zennotes/bridge-contract": "2.77.0-boundaries.h3ebf6d85eb9ac49b",
- "@zennotes/shared-domain": "2.77.0-boundaries.h3ebf6d85eb9ac49b",
+ "@zennotes/bridge-contract": "2.78.0-boundaries.h328ca592d70ad8f4",
+ "@zennotes/shared-domain": "2.78.0-boundaries.h328ca592d70ad8f4",
"dompurify": "^3.3.4",
"function-plot": "^1.25.3",
"gray-matter": "^4.0.3",
@@ -3711,7 +3711,7 @@
"jsxgraph": "^1.12.2",
"katex": "^0.16.15",
"mdast": "^3.0.0",
- "pele": "1.6.0",
+ "pele": "1.12.0",
"prettier": "^3.8.2",
"rehype-highlight": "^7.0.1",
"rehype-katex": "^7.0.1",
@@ -3745,19 +3745,19 @@
}
},
"node_modules/@zennotes/bridge-contract": {
- "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b",
- "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz",
- "integrity": "sha512-wMp1ytIf4U1v4ngHlJ7CegQfQPC1OhyQQXoM4O+tPXmaTWYIUsPHVhgs+i7sptraWQJJZ636Ybn27jDQHTIQ6Q==",
+ "version": "2.78.0-boundaries.h328ca592d70ad8f4",
+ "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz",
+ "integrity": "sha512-7Hm+frjf+iiQg9x0UXaZzZ0Wbbbg2aqbHb7W2iXGAaOEjvQXnxwzRv8vOGlPlf3KKsQvz8X0TRQ1TzGoOh/vAQ==",
"license": "MIT"
},
"node_modules/@zennotes/shared-domain": {
- "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b",
- "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz",
- "integrity": "sha512-x52yLX/DGx2tNN+3F5TBakphOnLq5TtXIhFM5prS1pp49i6IacXRo+ZZ9wmLr54zm4rqL6x2mfSbd4Dk3+fbJw==",
+ "version": "2.78.0-boundaries.h328ca592d70ad8f4",
+ "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz",
+ "integrity": "sha512-SlD18XyKoX0/68yPwZKUuitgsd3lzOLbsnC6vytvem/qfC3LdHDRWg4LaphxLO7h3bMChOLWUhd9MG36n5hldw==",
"license": "MIT",
"dependencies": {
"@scure/bip39": "2.4.0",
- "@zennotes/bridge-contract": "2.77.0-boundaries.h3ebf6d85eb9ac49b",
+ "@zennotes/bridge-contract": "2.78.0-boundaries.h328ca592d70ad8f4",
"hash-wasm": "4.12.0",
"lz-string": "^1.5.0"
}
@@ -7623,9 +7623,9 @@
}
},
"node_modules/pele": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/pele/-/pele-1.6.0.tgz",
- "integrity": "sha512-VfAiVItsOGlGhvnBfphdT28VCG7mXMZxQNOwAGsCtxI2ztbUsT39MraEOYjsapLxVETkdUZscm0DdotYDQMLdw==",
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/pele/-/pele-1.12.0.tgz",
+ "integrity": "sha512-3OdOK6rvkaTB569R4/I8U0H82MqPRxj7vfkbowa2rRhgSBPFhvWlfFy5eae0jP+2A6hE8abE17nsdyxJz2VBiQ==",
"license": "MIT",
"engines": {
"node": ">=20"
diff --git a/package.json b/package.json
index e75f1bc..7e5927b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "zennotes-android",
"private": true,
- "version": "1.1.47",
+ "version": "1.1.48",
"type": "module",
"description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core",
"homepage": "https://zennotes.org",
@@ -43,9 +43,9 @@
"@lezer/highlight": "^1.2.5",
"@replit/codemirror-vim": "^6.3.0",
"@xyflow/react": "^12.12.0",
- "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz",
- "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz",
- "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz",
+ "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz",
+ "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz",
+ "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz",
"codemirror": "^6.0.1",
"dompurify": "^3.4.17",
"function-plot": "^1.26.0",
diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts
index 15df644..b65c70f 100644
--- a/src/bridge/mobile-bridge.ts
+++ b/src/bridge/mobile-bridge.ts
@@ -86,6 +86,7 @@ import {
import { emitVaultChange, onVaultChange, onOpenNoteRequested, requestOpenNote } from './events'
import type { BackgroundSyncHost } from './background-sync'
import { randomUUID } from './uuid'
+import { openAssetExternally } from './open-asset'
import { renderTikzOnDevice } from './tikz'
import { fetchLinkMetadataOnDevice } from './link-metadata'
import {
@@ -155,7 +156,7 @@ import {
import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core'
import { isPhoneViewport } from '../viewport'
-let appVersion = '1.1.47'
+let appVersion = '1.1.48'
export async function loadNativeAppVersion(): Promise {
try {
@@ -444,6 +445,18 @@ function activeMobileVault(): MobileVault {
return current
}
+/** The open vault's root as a native URI (`file://`, or a SAF `content://`
+ * tree for a picked folder), where the Calendar widget writes a tick; null
+ * for a remote workspace, whose notes are not on this device. */
+export function activeVaultNativeRoot(): string | null {
+ try {
+ const current = activeVault()
+ return current instanceof MobileVault ? current.fs.rootUri : null
+ } catch {
+ return null
+ }
+}
+
/** The open vault as background sync sees it: ready when it is an on-device
* vault linked to the signed-in account, the check app-core's automatic
* sync makes before each run. */
@@ -1237,7 +1250,7 @@ export const mobileBridge: ZenBridge = {
// 'desktop-only' token makes app-core show its friendly toast.
openExternalFile: async () => ({ ok: false, error: 'desktop-only' }),
openExternalUrl: async () => ({ ok: false, error: 'desktop-only' }),
- openAssetExternally: async () => notImplemented('openAssetExternally'),
+ openAssetExternally: (relPath) => openAssetExternally(activeVault(), relPath),
// Bookmark cards fetch open-graph metadata natively (link-metadata.ts) —
// a WKWebView fetch of an arbitrary page would be CORS-blocked.
fetchLinkMetadata: (url) => fetchLinkMetadataOnDevice(url),
diff --git a/src/bridge/native-fs.ts b/src/bridge/native-fs.ts
index 0948d2a..86a0380 100644
--- a/src/bridge/native-fs.ts
+++ b/src/bridge/native-fs.ts
@@ -317,7 +317,8 @@ export class NativeFs {
return CloudFiles.inspect({ uri: knownUri ?? await this.fileUri(relPath), textCandidate })
}
- private async fileUri(relPath: string): Promise {
+ /** Native URI of a vault file: content:// under a picked folder, file:// otherwise. */
+ async fileUri(relPath: string): Promise {
if (this.saf) return (await SafFs.stat({ root: this.cloudRootUri!, path: relPath })).uri
const location = this.loc(relPath)
if (location.directory === undefined) return location.path
diff --git a/src/bridge/open-asset.test.ts b/src/bridge/open-asset.test.ts
new file mode 100644
index 0000000..ce74c5a
--- /dev/null
+++ b/src/bridge/open-asset.test.ts
@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict'
+import { it } from 'node:test'
+import { loadMobileModule } from '../../tooling/load-mobile-module.ts'
+
+// The shell never implemented openAssetExternally. App-core now sends a PDF
+// here wherever the WebView cannot draw one (Dave, Discord, 2026-10-27), so
+// this is how a PDF opens at all on Android.
+
+class FakeRemoteVault {
+ readonly client: { fetchAssetBase64(rel: string): Promise<{ base64: string }> }
+ constructor(client: FakeRemoteVault['client']) {
+ this.client = client
+ }
+}
+
+interface Harness {
+ api: Record
+ opened: string[]
+ written: Array<{ path: string; data: string }>
+ failOpen: Error | null
+}
+
+async function load(): Promise {
+ const harness: Harness = { api: {}, opened: [], written: [], failOpen: null }
+ harness.api = await loadMobileModule('./src/bridge/open-asset.ts', {
+ '@capacitor/core': {
+ registerPlugin: () => ({
+ open: async ({ uri }: { uri: string }) => {
+ if (harness.failOpen) throw harness.failOpen
+ harness.opened.push(uri)
+ }
+ })
+ },
+ '@capacitor/filesystem': {
+ Directory: { Cache: 'CACHE' },
+ Filesystem: {
+ mkdir: async () => {},
+ writeFile: async ({ path, data }: { path: string; data: string }) =>
+ void harness.written.push({ path, data }),
+ getUri: async ({ path }: { path: string }) => ({ uri: `file:///data/user/0/md.zennotes/cache/${path}` })
+ }
+ },
+ './remote-vault': { RemoteVault: FakeRemoteVault }
+ })
+ return harness
+}
+
+function localVault(files: Record) {
+ return {
+ fs: {
+ statOrNull: async (rel: string) => (rel in files ? { type: 'file' } : null),
+ fileUri: async (rel: string) => files[rel]
+ }
+ }
+}
+
+const VAULT_FILE =
+ 'file:///storage/emulated/0/Android/data/md.zennotes/files/ZenNotes/My%20Vault/assets/Tourtickets.pdf'
+
+it('hands an on-device vault file to the viewer by its native URI', async () => {
+ const h = await load()
+ const result = await h.api.openAssetExternally(
+ localVault({ 'assets/Tourtickets.pdf': VAULT_FILE }),
+ 'assets/Tourtickets.pdf'
+ )
+ assert.deepEqual(result, { ok: true })
+ assert.deepEqual(h.opened, [VAULT_FILE])
+})
+
+it('says the file is missing instead of opening nothing', async () => {
+ const h = await load()
+ const result = await h.api.openAssetExternally(localVault({}), 'assets/Gone.pdf')
+ assert.equal(result.ok, false)
+ assert.match(result.error, /missing/)
+ assert.deepEqual(h.opened, [])
+})
+
+it('refuses a path that climbs out of the vault', async () => {
+ const h = await load()
+ const result = await h.api.openAssetExternally(
+ localVault({ 'assets/Tourtickets.pdf': VAULT_FILE }),
+ '../outside.pdf'
+ )
+ assert.equal(result.ok, false)
+ assert.deepEqual(h.opened, [])
+})
+
+it("passes on the phone's answer when no app views the file", async () => {
+ const h = await load()
+ h.failOpen = new Error('No app on this phone opens this kind of file.')
+ const result = await h.api.openAssetExternally(
+ localVault({ 'assets/Tourtickets.pdf': VAULT_FILE }),
+ 'assets/Tourtickets.pdf'
+ )
+ assert.deepEqual(result, { ok: false, error: 'No app on this phone opens this kind of file.' })
+})
+
+it("copies a remote vault's asset into a fresh cache folder per open, then opens the copy", async () => {
+ const h = await load()
+ const vault = new FakeRemoteVault({ fetchAssetBase64: async () => ({ base64: 'JVBERi0=' }) })
+ await h.api.openAssetExternally(vault, 'assets/Tourtickets.pdf')
+ await h.api.openAssetExternally(vault, 'assets/Tourtickets.pdf')
+
+ assert.equal(h.written.length, 2)
+ assert.notEqual(h.written[0].path, h.written[1].path)
+ for (const { path, data } of h.written) {
+ assert.match(path, /^open-asset\/[^/]+\/Tourtickets\.pdf$/)
+ assert.equal(data, 'JVBERi0=')
+ }
+ assert.deepEqual(
+ h.opened,
+ h.written.map(({ path }) => `file:///data/user/0/md.zennotes/cache/${path}`)
+ )
+})
diff --git a/src/bridge/open-asset.ts b/src/bridge/open-asset.ts
new file mode 100644
index 0000000..7d0ed68
--- /dev/null
+++ b/src/bridge/open-asset.ts
@@ -0,0 +1,67 @@
+/**
+ * Opening a vault attachment with another app: the bridge contract's
+ * `openAssetExternally`, which this shell left unimplemented, so a tap on an
+ * attachment chip ended in an error toast.
+ *
+ * Android hands a file over with a VIEW intent (OpenFilePlugin.java), to the
+ * app the user keeps for its type. The WebView has no PDF viewer, so this is
+ * also the only way a PDF shows at all: app-core sends a PDF here instead of
+ * drawing an empty frame wherever `navigator.pdfViewerEnabled` is false (Dave,
+ * Discord, 2026-10-27).
+ *
+ * A remote vault's asset exists only on the server, so it is copied into a
+ * fresh cache directory first, as the iPhone shell does: fresh each time, so
+ * opening it again never overwrites the copy a viewer still has on screen.
+ */
+import { registerPlugin } from '@capacitor/core'
+import { Directory, Filesystem } from '@capacitor/filesystem'
+import { RemoteVault } from './remote-vault'
+import { randomUUID } from './uuid'
+import type { MobileVault } from './vault-fs'
+import { resolveSafeRel } from './vault-core'
+
+interface OpenFilePlugin {
+ open(options: { uri: string }): Promise
+}
+
+const OpenFile = registerPlugin('OpenFile')
+
+/** Cache subtree holding copies staged out of a remote vault. */
+const OPEN_CACHE_DIR = 'open-asset'
+
+export async function openAssetExternally(
+ vault: MobileVault | RemoteVault,
+ relPath: string
+): Promise<{ ok: boolean; error?: string }> {
+ let rel: string
+ try {
+ rel = resolveSafeRel(relPath)
+ } catch {
+ return { ok: false, error: 'That attachment path is not inside this vault.' }
+ }
+ try {
+ const uri =
+ vault instanceof RemoteVault ? await stageRemoteAsset(vault, rel) : await localAssetUri(vault, rel)
+ if (!uri) return { ok: false, error: 'The attachment is missing from this vault.' }
+ await OpenFile.open({ uri })
+ return { ok: true }
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
+ }
+}
+
+async function localAssetUri(vault: MobileVault, rel: string): Promise {
+ if (!(await vault.fs.statOrNull(rel))) return null
+ return await vault.fs.fileUri(rel)
+}
+
+async function stageRemoteAsset(vault: RemoteVault, rel: string): Promise {
+ const { base64 } = await vault.client.fetchAssetBase64(rel)
+ const name = rel.split('/').pop() || 'attachment'
+ const dir = `${OPEN_CACHE_DIR}/${randomUUID()}`
+ await Filesystem.mkdir({ path: dir, directory: Directory.Cache, recursive: true }).catch(() => {})
+ const path = `${dir}/${name}`
+ await Filesystem.writeFile({ path, directory: Directory.Cache, data: base64 })
+ const { uri } = await Filesystem.getUri({ path, directory: Directory.Cache })
+ return uri
+}
diff --git a/src/bridge/widget-calendar.test.ts b/src/bridge/widget-calendar.test.ts
new file mode 100644
index 0000000..e9c5b67
--- /dev/null
+++ b/src/bridge/widget-calendar.test.ts
@@ -0,0 +1,46 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { parseWidgetActions, seenLine, type WidgetAction } from './widget-calendar.ts'
+
+const tick: WidgetAction = {
+ at: 2,
+ vaultKey: 'zn://vaults/My Vault',
+ id: 'inbox/Daily/2026-10-27.md#0',
+ path: 'inbox/Daily/2026-10-27.md',
+ line: 4,
+ raw: '- [ ] Buy Cougar Gold',
+ toggled: '- [x] Buy Cougar Gold',
+ checked: true,
+ written: true
+}
+
+test('the log comes back oldest first, with written defaulting to false', () => {
+ const { written: _w, ...unwritten } = tick
+ const parsed = parseWidgetActions([tick, { ...unwritten, at: 1, checked: false }])
+ assert.deepEqual(parsed.map((a) => [a.at, a.checked, a.written]), [
+ [1, false, false],
+ [2, true, true]
+ ])
+})
+
+test('anything malformed from the native side is dropped, not trusted', () => {
+ assert.deepEqual(parseWidgetActions(null), [])
+ assert.deepEqual(parseWidgetActions({ actions: [tick] }), [])
+ assert.deepEqual(
+ parseWidgetActions([
+ { ...tick, id: '' },
+ { ...tick, path: 42 },
+ { ...tick, at: Number.NaN },
+ { ...tick, checked: 'yes' },
+ { ...tick, raw: undefined },
+ 'tick',
+ null
+ ]),
+ []
+ )
+})
+
+test('core looks a task up by the line the widget showed before the tap', () => {
+ assert.equal(seenLine(tick), '- [ ] Buy Cougar Gold')
+ assert.equal(seenLine({ ...tick, checked: false }), '- [x] Buy Cougar Gold')
+})
diff --git a/src/bridge/widget-calendar.ts b/src/bridge/widget-calendar.ts
new file mode 100644
index 0000000..8a0fc0e
--- /dev/null
+++ b/src/bridge/widget-calendar.ts
@@ -0,0 +1,92 @@
+/**
+ * The Calendar widget's part of the widget snapshot, and the checkbox taps
+ * it hands back. Same file, byte for byte, in both phone shells.
+ *
+ * App-core's `getCalendarWidgetData` builds the calendar (the Calendar
+ * sheet's grid marks and week agenda for the months around today); the
+ * shell adds where the vault lives and how far it has read the widget's
+ * taps. A tap never waits for the app: the native side logs it, draws the
+ * row ticked straight away and, on Android, writes the task's line into the
+ * note itself when that line is still exactly what the snapshot said
+ * (`raw` swapped for `toggled`, both computed by core, so no native code
+ * parses a task). The log stays until the app has applied it and published
+ * a snapshot whose `actionsAck` covers it; only then does the native side
+ * forget it, so a snapshot computed before the app saw a tap never undoes
+ * the tick on screen.
+ *
+ * Only types and pure helpers live here (node --test covers them); the
+ * wiring is in widgets.ts.
+ */
+import type { CalendarWidgetData } from '@zennotes/app-core/calendar'
+
+export interface WidgetCalendar extends CalendarWidgetData {
+ /** Which vault this is (its `zn://` root). A tap logged against another
+ * vault is dropped rather than applied to whatever note shares its path. */
+ vaultKey: string
+ /** Android only: the open vault's root as a native URI (`file://` or a
+ * SAF `content://` tree) the widget writes a tick into. Null on iPhone,
+ * where the widget cannot reach the vault, and for a remote workspace. */
+ vaultRoot: string | null
+ /** The newest tap (`at`) the app has applied. */
+ actionsAck: number
+}
+
+/** One checkbox tap, as the native widget logged it. */
+export interface WidgetAction {
+ /** ms since epoch; unique within the log (the native side bumps a tie). */
+ at: number
+ vaultKey: string
+ /** VaultTask id (`${sourcePath}#${taskIndex}`). */
+ id: string
+ path: string
+ /** 0-based line of the task in its file. */
+ line: number
+ /** The task's line unticked, as the snapshot gave it. */
+ raw: string
+ /** The same line ticked, as core's toggle writes it. */
+ toggled: string
+ /** The state the tap asked for. */
+ checked: boolean
+ /** The widget already wrote the line into the note (Android, when the
+ * line on disk still matched); the app only needs to rescan it. */
+ written: boolean
+}
+
+/** Native data is checked, not trusted: anything malformed is dropped. */
+export function parseWidgetActions(input: unknown): WidgetAction[] {
+ if (!Array.isArray(input)) return []
+ const out: WidgetAction[] = []
+ for (const item of input) {
+ if (!item || typeof item !== 'object') continue
+ const a = item as Record
+ if (
+ typeof a.at !== 'number' || !Number.isFinite(a.at) ||
+ typeof a.vaultKey !== 'string' ||
+ typeof a.id !== 'string' || !a.id ||
+ typeof a.path !== 'string' || !a.path ||
+ typeof a.line !== 'number' ||
+ typeof a.raw !== 'string' ||
+ typeof a.toggled !== 'string' ||
+ typeof a.checked !== 'boolean'
+ ) {
+ continue
+ }
+ out.push({
+ at: a.at,
+ vaultKey: a.vaultKey,
+ id: a.id,
+ path: a.path,
+ line: a.line,
+ raw: a.raw,
+ toggled: a.toggled,
+ checked: a.checked,
+ written: a.written === true
+ })
+ }
+ return out.sort((x, y) => x.at - y.at)
+}
+
+/** The line the widget showed before the tap: what core looks the task up by. */
+export function seenLine(action: WidgetAction): string {
+ return action.checked ? action.raw : action.toggled
+}
diff --git a/src/bridge/widget-snapshot.ts b/src/bridge/widget-snapshot.ts
index d888855..191a0af 100644
--- a/src/bridge/widget-snapshot.ts
+++ b/src/bridge/widget-snapshot.ts
@@ -14,6 +14,7 @@
*/
import type { NoteMeta } from '@zennotes/bridge-contract/ipc'
import type { VaultTask } from '@zennotes/shared-domain/tasks'
+import type { WidgetCalendar } from './widget-calendar.ts'
export const WIDGET_SNAPSHOT_VERSION = 1
/** The list scrolls; a dozen keeps the widget useful at any height. */
@@ -76,6 +77,9 @@ export interface WidgetSnapshot {
/** False until the first task scan for this vault has landed, so the
* widget shows "loading" rather than a misleading "All clear". */
tasksReady: boolean
+ /** The Calendar widget's month marks and week agenda (widget-calendar.ts);
+ * null while it could not be built. */
+ calendar: WidgetCalendar | null
}
/** ZenNotes' default theme (dark-hard), used before the first publish and
diff --git a/src/bridge/widgets.ts b/src/bridge/widgets.ts
index 9a6740d..2a0e3d2 100644
--- a/src/bridge/widgets.ts
+++ b/src/bridge/widgets.ts
@@ -17,14 +17,20 @@
* `tasksSurfaceVisible`), which on the phone is rarely the case while
* editing. Notes whose updatedAt moved get a per-note rescan; a vault switch
* (or a big batch, e.g. a folder vault landing many files) gets one full scan.
+ *
+ * The Calendar widget's checkbox taps come back through here too: the
+ * native side logs each one (widget-calendar.ts), this applies the log at
+ * launch and on every return to the foreground, and the next snapshot's
+ * `actionsAck` tells the widget which taps it may forget.
*/
import { App as CapApp } from '@capacitor/app'
import { Capacitor, registerPlugin } from '@capacitor/core'
import { getShellSnapshot, subscribeShell, type ShellSnapshot } from '@zennotes/app-core/shell'
-import { getTasksSnapshot, subscribeTasks, refreshTasks, getTodayTasks, type TasksSnapshot } from '@zennotes/app-core/tasks'
+import { getTasksSnapshot, subscribeTasks, refreshTasks, getTodayTasks, setTaskChecked, type TasksSnapshot } from '@zennotes/app-core/tasks'
+import { getCalendarWidgetData } from '@zennotes/app-core/calendar'
import { subscribeSettings } from '@zennotes/app-core/settings'
import { toIsoDateLocal } from '@zennotes/shared-domain/tasks'
-import { isMobileNoteIndexReady } from './mobile-bridge'
+import { activeVaultNativeRoot, isMobileNoteIndexReady } from './mobile-bridge'
import { getPinnedNotes, subscribePins } from '../ui-mobile/pins'
import {
WIDGET_SNAPSHOT_VERSION,
@@ -34,6 +40,7 @@ import {
type WidgetSnapshot,
type WidgetTheme
} from './widget-snapshot'
+import { parseWidgetActions, seenLine, type WidgetCalendar } from './widget-calendar'
interface ZenWidgetsPlugin {
update(options: { snapshot: string }): Promise
@@ -41,6 +48,10 @@ interface ZenWidgetsPlugin {
/** The newest `zennotes://` link that launched or woke this process, once
* (deep-links.ts). Null when the app was opened normally. */
consumeLaunchLink(): Promise<{ url: string | null }>
+ /** The Calendar widget's logged checkbox taps, oldest first. Reading
+ * leaves them in place; a snapshot whose `calendar.actionsAck` covers a
+ * tap is what clears it. */
+ readActions(): Promise<{ actions: unknown }>
}
export const ZenWidgets = registerPlugin('ZenWidgets')
@@ -58,6 +69,18 @@ let lastPayload = ''
let taskVaultKey: string | null = null
let tasksSettled = false
let knownUpdatedAt = new Map()
+let inFlight: Promise | null = null
+let publishAgain = false
+let actionsAck = 0
+let actionsWanted = true
+let applyingActions = false
+let actionsRetryTimer = 0
+let actionsRetries = 0
+/** A tap that cannot be applied yet (its note open with a buffer the
+ * foreground resync has yet to reload, a write that failed) is tried again
+ * after this, a few times, then on the next return to the foreground. */
+const ACTIONS_RETRY_MS = 4000
+const ACTIONS_MAX_RETRIES = 5
/** The pins key this shell uses everywhere (note-actions.tsx). */
function pinsKey(state: StoreState): string | null {
@@ -68,7 +91,29 @@ function themeMode(): WidgetTheme['mode'] {
return document.documentElement.dataset.themeMode === 'light' ? 'light' : 'dark'
}
-function buildSnapshot(state: StoreState, now: Date): Omit {
+/** The Calendar widget's block; null when core could not build it (a note
+ * read failed mid-switch), which the widget shows as "open the app". */
+async function buildCalendar(state: StoreState, now: Date): Promise {
+ const vaultKey = state.vault?.root
+ if (!vaultKey) return null
+ // Taken before the data is read: a tap applied while it is being built
+ // must not be acked by a snapshot that predates it, or the widget would
+ // forget the tap and draw the box empty again.
+ const ack = actionsAck
+ try {
+ return {
+ ...(await getCalendarWidgetData(now)),
+ vaultKey,
+ vaultRoot: Capacitor.getPlatform() === 'android' ? activeVaultNativeRoot() : null,
+ actionsAck: ack
+ }
+ } catch (err) {
+ console.error('calendar widget data failed', err)
+ return null
+ }
+}
+
+async function buildSnapshot(state: StoreState, now: Date): Promise> {
const style = getComputedStyle(document.documentElement)
const today = getTodayTasks(now)
const { tasks, counts } = selectWidgetTasks(today.tasks, today.overdueCount, toIsoDateLocal(now))
@@ -79,7 +124,8 @@ function buildSnapshot(state: StoreState, now: Date): Omit {
if (!state.vault || !state.workspaceRestored) return
let body: Omit
try {
- body = buildSnapshot(state, new Date())
+ body = await buildSnapshot(state, new Date())
} catch (err) {
console.error('widget snapshot failed', err)
return
}
+ // The vault switched while the calendar was read: that publish is stale.
+ if (getShellSnapshot().vault?.root !== state.vault.root) return
const payload = JSON.stringify(body)
if (payload === lastPayload) return
lastPayload = payload
@@ -103,12 +151,28 @@ async function publish(): Promise {
await ZenWidgets.update({ snapshot: JSON.stringify(snapshot) }).catch(() => {})
}
+/** One publish at a time: building the calendar awaits note reads, and two
+ * overlapping publishes could land out of order. */
+function publishNow(): void {
+ if (inFlight) {
+ publishAgain = true
+ return
+ }
+ inFlight = publish().finally(() => {
+ inFlight = null
+ if (publishAgain) {
+ publishAgain = false
+ publishNow()
+ }
+ })
+}
+
function schedule(): void {
if (timer) return
const wait = Math.max(PUBLISH_DEBOUNCE_MS, lastPublishedAt + PUBLISH_MIN_INTERVAL_MS - Date.now())
timer = window.setTimeout(() => {
timer = 0
- void publish()
+ publishNow()
}, wait)
}
@@ -116,7 +180,64 @@ function flush(): void {
if (!timer) return
window.clearTimeout(timer)
timer = 0
- void publish()
+ publishNow()
+}
+
+/**
+ * Apply the Calendar widget's logged taps once the vault is ready, in the
+ * order they were made, through core's own toggle, which finds the task
+ * again or leaves it alone when its line changed. A tap the widget already
+ * wrote goes through it too: it finds the line in the wanted state and
+ * only rescans, and a later tap on the same task still wins. A tap that
+ * cannot be applied yet stops the pass there, unacked, so nothing after it
+ * jumps ahead. A tap logged against another vault is dropped: the same path
+ * in this vault is a different note.
+ */
+async function applyWidgetActions(): Promise {
+ if (applyingActions || !actionsWanted) return
+ const state = getShellSnapshot()
+ const root = state.vault?.root
+ if (!root || !state.workspaceRestored || !isMobileNoteIndexReady()) return
+ applyingActions = true
+ actionsWanted = false
+ try {
+ const actions = parseWidgetActions((await ZenWidgets.readActions()).actions)
+ let ack = actionsAck
+ let blocked = false
+ for (const action of actions) {
+ if (action.at <= actionsAck) continue
+ if (action.vaultKey === root) {
+ const result = await setTaskChecked(action.id, seenLine(action), action.checked)
+ if (result === 'retry') {
+ blocked = true
+ break
+ }
+ if (result === 'stale') {
+ console.warn('[zen] a widget tick no longer matches its note; left alone:', action.path)
+ }
+ }
+ ack = Math.max(ack, action.at)
+ }
+ window.clearTimeout(actionsRetryTimer)
+ if (blocked && actionsRetries < ACTIONS_MAX_RETRIES) {
+ actionsRetries += 1
+ actionsRetryTimer = window.setTimeout(() => {
+ actionsWanted = true
+ void applyWidgetActions()
+ }, ACTIONS_RETRY_MS)
+ } else if (!blocked) {
+ actionsRetries = 0
+ }
+ if (ack > actionsAck) {
+ actionsAck = ack
+ lastPayload = ''
+ schedule()
+ }
+ } catch (err) {
+ console.error('widget actions failed', err)
+ } finally {
+ applyingActions = false
+ }
}
function reconcileTasks(state: StoreState, prev: StoreState | null, tasks: TasksSnapshot, previousTasks: TasksSnapshot | null): void {
@@ -158,6 +279,7 @@ export function installWidgetPublisher(): () => void {
// Store subscriptions can fire synchronously when refreshTasks starts.
shell = nextShell; tasks = nextTasks
reconcileTasks(nextShell, previousShell, nextTasks, previousTasks)
+ void applyWidgetActions()
schedule()
}
const unsubShell = subscribeShell(changed)
@@ -165,9 +287,16 @@ export function installWidgetPublisher(): () => void {
const unsubSettings = subscribeSettings(schedule)
const unsubPins = subscribePins(schedule)
const appState = CapApp.addListener('appStateChange', ({ isActive }) => {
- if (!isActive) flush()
+ if (!isActive) {
+ flush()
+ return
+ }
+ actionsWanted = true
+ actionsRetries = 0
+ void applyWidgetActions()
})
reconcileTasks(getShellSnapshot(), null, getTasksSnapshot(), null)
+ void applyWidgetActions()
schedule()
return () => {
unsubShell(); unsubTasks(); unsubSettings()
diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx
index 49629c3..02c1386 100644
--- a/src/ui-mobile/MobileDrawer.tsx
+++ b/src/ui-mobile/MobileDrawer.tsx
@@ -28,6 +28,7 @@ import { archiveNote, openNoteMenu, trashNote } from './note-actions'
import { refreshVault } from './refresh'
import { SwipeRow } from './SwipeRow'
import { drawerFileRows, fileExtensionLabel, type DrawerFileRow } from './browse-files'
+import { hiddenDrawerFolders } from './drawer-folders'
import { SheetHandle } from './SheetHandle'
import { getStoragePref } from '../bridge/icloud'
import {
@@ -752,13 +753,7 @@ export function MobileDrawer(): React.JSX.Element | null {
const pins = usePins(vaultRoot)
const { daily: dailyDir, weekly: weeklyDir, monthly: monthlyDir } = browse.dateDirectories
const noteSortOrder = browse.noteSortOrder
- const dateDirs = useMemo(() => {
- const dirs = new Set()
- if (dailyDir) dirs.add(dailyDir)
- if (weeklyDir) dirs.add(weeklyDir)
- if (monthlyDir) dirs.add(monthlyDir)
- return dirs
- }, [dailyDir, weeklyDir, monthlyDir])
+ const hiddenFolders = useMemo(() => hiddenDrawerFolders(browse), [browse])
const [path, setPath] = useState('')
// On each open, jump to the folder a breadcrumb tap requested ('' for a
@@ -769,13 +764,19 @@ export function MobileDrawer(): React.JSX.Element | null {
const { childFolders, childDatabases, childNotes, childFiles } = useMemo(() => {
const rows = getBrowseDirectory(browse, path, pins)
+ // A note's breadcrumb can open the drawer inside a folder the list hides
+ // (`Journal` around `Journal/Daily`); there the periodic folder is shown,
+ // or the drawer would call a folder empty that is not.
+ const hiding = !hiddenFolders.has(path)
return {
- childFolders: rows.folders.map(row => [row.directory, row.title] as [string, string]),
+ childFolders: rows.folders
+ .filter(row => !hiding || !hiddenFolders.has(row.directory))
+ .map(row => [row.directory, row.title] as [string, string]),
childDatabases: rows.databases.map(row => [row.path, row.title, row.directory] as [string, string, string]),
childNotes: [...rows.notes],
childFiles: drawerFileRows(rows)
}
- }, [browse, path, pins])
+ }, [browse, path, pins, hiddenFolders])
if (!open) return null
@@ -800,7 +801,6 @@ export function MobileDrawer(): React.JSX.Element | null {
dailyDir={dailyDir}
weeklyDir={weeklyDir}
monthlyDir={monthlyDir}
- dateDirs={dateDirs}
path={path}
setPath={setPath}
childFolders={childFolders}
@@ -875,7 +875,6 @@ function MobileDrawerBody(props: {
dailyDir: string | null
weeklyDir: string | null
monthlyDir: string | null
- dateDirs: Set
path: string
setPath: (p: string) => void
childFolders: Array<[string, string]>
@@ -894,7 +893,6 @@ function MobileDrawerBody(props: {
dailyDir,
weeklyDir,
monthlyDir,
- dateDirs,
path,
setPath,
childFolders,
@@ -1171,7 +1169,7 @@ function MobileDrawerBody(props: {
onClick={() => setPath(subpath)}
{...lp(() => setFolderMenu({ kind: 'folder', subpath, name, host: captureMobileWorkspace() }))}
>
-
+
{name}
{isPinned && (
diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx
index 23210ba..937deb6 100644
--- a/src/ui-mobile/MobileShell.tsx
+++ b/src/ui-mobile/MobileShell.tsx
@@ -11,6 +11,7 @@ import ReactDOM from 'react-dom/client'
import { App as CapApp } from '@capacitor/app'
import { Haptics, ImpactStyle } from '@capacitor/haptics'
import { Keyboard } from '@capacitor/keyboard'
+import { Capacitor } from '@capacitor/core'
import { getShellSnapshot, useShellSnapshot, subscribeShell, getAdjacentNotePath,
getTagPresenceSnapshot, subscribeTagPresence } from '@zennotes/app-core/shell'
import { getBrowseSnapshot, requestCreateBrowseFolder, requestRenameBrowseDatabase,
@@ -20,6 +21,7 @@ import { getWorkspaceSnapshot, useWorkspaceSnapshot, subscribeWorkspace, readPer
import { getSettingsSnapshot, useSettingsSnapshot, subscribeSettings, setSettingsVisible, setEditorFontSize,
showSettingsPage } from '@zennotes/app-core/settings'
import { DateCalendar } from '@zennotes/app-core/calendar'
+import { CLOSE_CALENDAR_SHEET_EVENT, SHOW_CALENDAR_EVENT, requestCalendar, type CalendarRequest } from './calendar-request'
import { useEditorPresentation, setEditorMode, hasEditorSelection, runEditorCommand } from '@zennotes/app-core/editor'
import { getTasksSnapshot, moveTaskToColumn, type KanbanGroupBy } from '@zennotes/app-core/tasks'
import { runAppCommand, showCommandPalette, showSearch, showTemplates, showOutline } from '@zennotes/app-core/commands'
@@ -435,8 +437,6 @@ function CreateSheet({ onClose }: { onClose: () => void }): React.JSX.Element {
)
}
-const SHOW_CALENDAR_EVENT = 'zn:show-calendar'
-
/**
* Open the Calendar. A phone has no room beside a note, and Home has no note
* to sit beside, so there it is a sheet of its own (CalendarSheet). A tablet
@@ -444,7 +444,7 @@ const SHOW_CALENDAR_EVENT = 'zn:show-calendar'
* its ••• row offers it.
*/
function showCalendar(): void {
- if (isPhoneWidth()) window.dispatchEvent(new Event(SHOW_CALENDAR_EVENT))
+ if (isPhoneWidth()) requestCalendar()
else window.dispatchEvent(new Event('zen:toggle-calendar'))
}
@@ -462,17 +462,43 @@ function showCalendar(): void {
* With daily and weekly notes both off there is nothing for it to open, so
* the row says how to turn daily notes on instead of disappearing.
*/
-function CalendarSheet({ onClose }: { onClose: () => void }): React.JSX.Element {
+function CalendarSheet({
+ onClose,
+ date,
+ focusAdd
+}: {
+ onClose: () => void
+ /** The widget's day; null opens on the calendar's own choice. */
+ date: string | null
+ focusAdd: boolean
+}): React.JSX.Element {
const { calendarAvailable } = useSettingsSnapshot()
+ // Android raises no keyboard for a focus nobody tapped, and the widget's
+ // "Add a task" asked for one. Core focuses the field on mount, but a link
+ // that just brought the app back can land that focus before the WebView
+ // has its window, so it is placed again right before the keyboard.
+ useEffect(() => {
+ if (!focusAdd || !calendarAvailable || Capacitor.getPlatform() !== 'android') return
+ const timer = window.setTimeout(() => {
+ document.querySelector('.zn-calendar-sheet [data-calendar-panel] form input')?.focus()
+ void Keyboard.show().catch(() => {})
+ }, 300)
+ return () => window.clearTimeout(timer)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
useEffect(() => {
window.addEventListener('zen:close-right-panel', onClose)
- return () => window.removeEventListener('zen:close-right-panel', onClose)
+ window.addEventListener(CLOSE_CALENDAR_SHEET_EVENT, onClose)
+ return () => {
+ window.removeEventListener('zen:close-right-panel', onClose)
+ window.removeEventListener(CLOSE_CALENDAR_SHEET_EVENT, onClose)
+ }
}, [onClose])
if (calendarAvailable) {
return (
-
+
)
}
@@ -518,6 +544,9 @@ function MobileNav(): React.JSX.Element | null {
const [createOpen, setCreateOpen] = useState(false)
const [fabOpen, setFabOpen] = useState(false)
const [calendarOpen, setCalendarOpen] = useState(false)
+ // Which day the sheet opens on; the key remounts it when a widget link
+ // asks for another day while it is already up.
+ const [calendarAt, setCalendarAt] = useState({ date: null as string | null, add: false, key: 0 })
const closeCalendar = useCallback(() => setCalendarOpen(false), [])
// First-run coach mark: the unlabeled ensō reads as a loading spinner to
// new users (real-user tested). Set by onboarding, cleared on first tap.
@@ -547,7 +576,17 @@ function MobileNav(): React.JSX.Element | null {
// the Calendar, which the ••• sheet opens too.
useEffect(() => {
const onCreate = (): void => setCreateOpen(true)
- const onCalendar = (): void => setCalendarOpen(true)
+ const onCalendar = (e: Event): void => {
+ const request = (e as CustomEvent).detail
+ // A tablet keeps the panel beside the open note and has no sheet;
+ // the widget's day does not reach it there.
+ if (request && !isPhoneWidth()) {
+ if (!document.querySelector('[data-calendar-panel]')) window.dispatchEvent(new Event('zen:toggle-calendar'))
+ return
+ }
+ setCalendarAt((prev) => ({ date: request?.date ?? null, add: request?.add ?? false, key: prev.key + 1 }))
+ setCalendarOpen(true)
+ }
window.addEventListener('zn:quick-create', onCreate)
window.addEventListener(SHOW_CALENDAR_EVENT, onCalendar)
return () => {
@@ -674,7 +713,14 @@ function MobileNav(): React.JSX.Element | null {
{sheetOpen && setSheetOpen(false)} />}
{createOpen && setCreateOpen(false)} />}
- {calendarOpen && }
+ {calendarOpen && (
+
+ )}
>
)
}
@@ -1565,6 +1611,28 @@ function useCalendarWeekMode(): void {
const RIGHT_PANEL_SELECTOR =
'[data-calendar-panel], [data-connections-panel], [data-comments-panel], .zn-app-shell section[class*="border-l"][class*="shrink-0"], .zn-app-shell aside[class*="border-l"][class*="shrink-0"], .zn-app-shell div[class*="border-l"][class*="shrink-0"][class*="flex-col"]'
+/** The reference pane (a pinned note or PDF). It drew as a full-screen sheet
+ * like the other right panels, but it lives outside EditorPane and never
+ * hears `zen:close-right-panel`: Done and Back did nothing over it, and its
+ * header's back chevron left the note for Home, where reopening the note
+ * brought the sheet straight back (Dave, Discord, 2026-10-27). */
+const REFERENCE_PANE_SELECTOR = '[data-pane-id="pinned-ref"]'
+
+/**
+ * Close the top-most right panel, the way its own close control would. The
+ * reference pane renders after the editor panes, so it is the one on top; it
+ * hides through core's Hide Reference Pane (the pane's own square button),
+ * which keeps the pin, so the note's card brings it back with one tap.
+ */
+function closeRightPanel(): void {
+ const reference = document.querySelector(REFERENCE_PANE_SELECTOR)
+ if (reference && reference.style.display !== 'none') {
+ runCommand('ref.toggle')
+ return
+ }
+ window.dispatchEvent(new Event('zen:close-right-panel'))
+}
+
function useRightPanelCloseButton(): void {
useEffect(() => {
if (!isPhoneWidth()) return
@@ -1581,9 +1649,7 @@ function useRightPanelCloseButton(): void {
button.type = 'button'
button.className = 'zn-panel-close'
button.textContent = 'Done'
- button.addEventListener('click', () => {
- window.dispatchEvent(new Event('zen:close-right-panel'))
- })
+ button.addEventListener('click', closeRightPanel)
document.body.appendChild(button)
} else if (!open && button) {
button.remove()
@@ -1861,13 +1927,15 @@ function useHeaderBackButton(): void {
'.zn-app-shell header.glass-header'
)) {
if (header.querySelector('.zn-header-back')) continue
+ // The reference sheet's chevron goes back to the note under it.
+ const inReference = header.closest(REFERENCE_PANE_SELECTOR) !== null
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'zn-header-back'
- btn.setAttribute('aria-label', 'Go back')
+ btn.setAttribute('aria-label', inReference ? 'Back to the note' : 'Go back')
btn.innerHTML =
''
- btn.addEventListener('click', goBack)
+ btn.addEventListener('click', inReference ? closeRightPanel : goBack)
header.insertAdjacentElement('afterbegin', btn)
}
}
@@ -1935,9 +2003,10 @@ function useSystemBackClose(): void {
escape()
return
}
- // 6. Full-screen right panels (calendar/outline/connections/comments).
+ // 6. Full-screen right panels (calendar/outline/connections/comments,
+ // and the reference pane above them).
if (document.querySelector(RIGHT_PANEL_SELECTOR)) {
- window.dispatchEvent(new Event('zen:close-right-panel'))
+ closeRightPanel()
return
}
// 7. The drawer.
diff --git a/src/ui-mobile/calendar-request.ts b/src/ui-mobile/calendar-request.ts
new file mode 100644
index 0000000..c6473a9
--- /dev/null
+++ b/src/ui-mobile/calendar-request.ts
@@ -0,0 +1,26 @@
+/**
+ * Asking MobileShell for the Calendar from outside it: the circle and the
+ * ••• sheet ask with no day, the home-screen widget's links with the day it
+ * showed (and `add` to put the cursor in "Add a task for ..."). An event, not
+ * an import, so deep-links.ts does not pull the shell's components in.
+ */
+export const SHOW_CALENDAR_EVENT = 'zn:show-calendar'
+
+export interface CalendarRequest {
+ /** `YYYY-MM-DD`, or null for the calendar's own choice (the open daily
+ * note's day, else today). */
+ date: string | null
+ add: boolean
+}
+
+export function requestCalendar(request?: CalendarRequest): void {
+ window.dispatchEvent(new CustomEvent(SHOW_CALENDAR_EVENT, { detail: request }))
+}
+
+export const CLOSE_CALENDAR_SHEET_EVENT = 'zn:close-calendar-sheet'
+
+/** Put the phone's Calendar sheet away (a widget link is about to show
+ * something else). A tablet's panel beside the note is left as it is. */
+export function closeCalendarSheet(): void {
+ window.dispatchEvent(new Event(CLOSE_CALENDAR_SHEET_EVENT))
+}
diff --git a/src/ui-mobile/deep-links.ts b/src/ui-mobile/deep-links.ts
index c283018..cda751f 100644
--- a/src/ui-mobile/deep-links.ts
+++ b/src/ui-mobile/deep-links.ts
@@ -27,6 +27,7 @@ import { getShellSnapshot, subscribeShell, type ShellSnapshot } from '@zennotes/
import { openNote, openAppPage } from '@zennotes/app-core/navigation'
import { getTasksSnapshot, refreshTasks, openTask } from '@zennotes/app-core/tasks'
import { runAppCommand } from '@zennotes/app-core/commands'
+import { openDateNote } from '@zennotes/app-core/calendar'
import { captureMobileWorkspace } from './workspace-context'
import { isMobileNoteIndexReady } from '../bridge/mobile-bridge'
import { ZenWidgets } from '../bridge/widgets'
@@ -35,6 +36,7 @@ import { goHome } from './nav'
import { closeNoteMenu } from './note-actions'
import { closeMobileSheet } from './sheet-state'
import { parseWidgetLink, type WidgetLink } from './widget-links'
+import { closeCalendarSheet, requestCalendar } from './calendar-request'
const DUPLICATE_WINDOW_MS = 5000
const LANDING_SETTLE_MS = 80
@@ -117,6 +119,10 @@ async function run(link: WidgetLink): Promise {
closeMobileSheet()
closeNoteMenu()
setDrawerOpen(false)
+ // The Calendar sheet too; a calendar link opens it again on its own day.
+ // Its own event, not core's right-panel one, which would also shut a
+ // tablet's outline or comments panel.
+ closeCalendarSheet()
const s = getShellSnapshot()
switch (link.kind) {
case 'new':
@@ -136,6 +142,17 @@ async function run(link: WidgetLink): Promise {
case 'home':
goHome()
return
+ case 'calendar':
+ requestCalendar({ date: link.date, add: link.add })
+ return
+ case 'day':
+ case 'week':
+ // A day with no note opens the Calendar on it, where "Day +" asks
+ // before creating one, as tapping that day in the app would.
+ if (!(await openDateNote(link.kind === 'day' ? 'daily' : 'weekly', link.date))) {
+ requestCalendar({ date: link.date, add: false })
+ }
+ return
}
}
diff --git a/src/ui-mobile/drawer-folders.test.ts b/src/ui-mobile/drawer-folders.test.ts
new file mode 100644
index 0000000..2e87a2a
--- /dev/null
+++ b/src/ui-mobile/drawer-folders.test.ts
@@ -0,0 +1,85 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { hiddenDrawerFolders, type DrawerFolderSource } from './drawer-folders.ts'
+
+const dates = { daily: 'Daily Notes', weekly: 'Weekly Notes', monthly: 'Monthly Notes' }
+const off = { daily: null, weekly: null, monthly: null }
+const folder = (directory: string) => ({ directory })
+const note = (directory: string, noteFolder = 'inbox') => ({ folder: noteFolder, directory })
+
+function browse(source: Partial): DrawerFolderSource {
+ return { folders: [], databases: [], notes: [], files: [], dateDirectories: dates, ...source }
+}
+
+function hidden(source: Partial): string[] {
+ return [...hiddenDrawerFolders(browse(source))].sort()
+}
+
+test('the folders the Daily, Weekly and Monthly rows open are not listed again under Notes', () => {
+ const source = {
+ folders: ['Daily Notes', 'Weekly Notes', 'Monthly Notes', 'Projects'].map(folder),
+ notes: [note('Daily Notes'), note('Weekly Notes'), note('Monthly Notes'), note('Projects')]
+ }
+ assert.deepEqual(hidden(source), ['Daily Notes', 'Monthly Notes', 'Weekly Notes'])
+})
+
+test('with periodic notes off, every folder is listed', () => {
+ assert.equal(hiddenDrawerFolders(browse({ folders: [folder('Daily Notes')], dateDirectories: off })).size, 0)
+})
+
+test('only the kinds that are on have rows of their own, so only those folders leave the list', () => {
+ const source = {
+ folders: ['Daily Notes', 'Weekly Notes'].map(folder),
+ dateDirectories: { ...off, daily: 'Daily Notes' }
+ }
+ assert.deepEqual(hidden(source), ['Daily Notes'])
+})
+
+test('a periodic folder leaves the list even when it also holds other notes, which its row still opens', () => {
+ const source = { folders: [folder('Daily Notes')], notes: [note('Daily Notes'), note('Daily Notes')] }
+ assert.deepEqual(hidden(source), ['Daily Notes', 'Monthly Notes', 'Weekly Notes'])
+})
+
+test('a folder holding only periodic folders goes too, rather than opening onto nothing', () => {
+ const source = {
+ folders: ['Journal', 'Journal/Daily', 'Journal/Weekly'].map(folder),
+ notes: [note('Journal/Daily'), note('Journal/Weekly')],
+ dateDirectories: { ...off, daily: 'Journal/Daily', weekly: 'Journal/Weekly' }
+ }
+ assert.deepEqual(hidden(source), ['Journal', 'Journal/Daily', 'Journal/Weekly'])
+})
+
+test('a folder around a periodic folder keeps its row when it holds anything else', () => {
+ const periodic = { ...off, daily: 'Journal/Daily' }
+ const spine = ['Journal', 'Journal/Daily'].map(folder)
+ assert.deepEqual(hidden({ folders: spine, notes: [note('Journal')], dateDirectories: periodic }), ['Journal/Daily'])
+ assert.deepEqual(
+ hidden({ folders: [...spine, folder('Journal/Trips')], dateDirectories: periodic }),
+ ['Journal/Daily']
+ )
+ assert.deepEqual(
+ hidden({ folders: spine, files: [folder('Journal')], dateDirectories: periodic }),
+ ['Journal/Daily']
+ )
+ assert.deepEqual(
+ hidden({ folders: spine, databases: [folder('Journal/Reading.base')], dateDirectories: periodic }),
+ ['Journal/Daily']
+ )
+})
+
+test('content of the same name outside the notes area does not keep a folder around', () => {
+ const source = {
+ folders: ['Journal', 'Journal/Daily'].map(folder),
+ notes: [note('Journal', 'archive'), note('Journal/Daily')],
+ dateDirectories: { ...off, daily: 'Journal/Daily' }
+ }
+ assert.deepEqual(hidden(source), ['Journal', 'Journal/Daily'])
+})
+
+test('a folder whose name merely starts like a periodic folder is not part of it', () => {
+ const source = {
+ folders: ['Journal', 'Journal/Daily', 'Journal/Daily Archive'].map(folder),
+ dateDirectories: { ...off, daily: 'Journal/Daily' }
+ }
+ assert.deepEqual(hidden(source), ['Journal/Daily'])
+})
diff --git a/src/ui-mobile/drawer-folders.ts b/src/ui-mobile/drawer-folders.ts
new file mode 100644
index 0000000..95818a9
--- /dev/null
+++ b/src/ui-mobile/drawer-folders.ts
@@ -0,0 +1,61 @@
+/**
+ * The folders the drawer's Notes list leaves out. The Daily, Weekly and
+ * Monthly Notes rows above it open those folders directly, so listing them
+ * again under Notes showed every periodic note twice: two rows, one folder
+ * (uNyanda, Discord 2026-10-27, after the layout fix for
+ * ZenNotes/zennotesandroid#17 had shipped). The desktop sidebar draws them
+ * once, too: periodic notes get their own Quick Access rows and the folders
+ * that only held them leave its tree.
+ *
+ * A folder that holds nothing but periodic folders (`Journal` around
+ * `Journal/Daily`) goes as well, so the list never offers a folder that opens
+ * onto nothing. One holding anything else, a note, a file, a database or a
+ * folder of its own, keeps its row. Nothing is lost by hiding: everything
+ * inside a periodic folder, including notes that are not periodic, is one tap
+ * away through its row.
+ */
+import { dirOf } from './note-order.ts'
+
+export interface DrawerFolderSource {
+ readonly folders: readonly { readonly directory: string }[]
+ readonly databases: readonly { readonly directory: string }[]
+ readonly notes: readonly { readonly folder: string; readonly directory: string }[]
+ readonly files?: readonly { readonly directory: string }[]
+ readonly dateDirectories: {
+ readonly daily: string | null
+ readonly weekly: string | null
+ readonly monthly: string | null
+ }
+}
+
+const NONE: ReadonlySet = new Set()
+
+export function hiddenDrawerFolders(browse: DrawerFolderSource): ReadonlySet {
+ const { daily, weekly, monthly } = browse.dateDirectories
+ const periodic = [daily, weekly, monthly].filter((dir): dir is string => !!dir)
+ if (periodic.length === 0) return NONE
+ const within = (path: string, dir: string): boolean => path === dir || path.startsWith(`${dir}/`)
+ const inPeriodic = (path: string): boolean => periodic.some(dir => within(path, dir))
+
+ const spine = new Set()
+ for (const dir of periodic) {
+ for (let parent = dirOf(dir); parent; parent = dirOf(parent)) spine.add(parent)
+ }
+ const kept = new Set()
+ const keepAround = (path: string): void => {
+ if (inPeriodic(path)) return
+ for (const dir of spine) if (within(path, dir)) kept.add(dir)
+ }
+ if (spine.size > 0) {
+ for (const note of browse.notes) if (note.folder === 'inbox') keepAround(note.directory)
+ for (const file of browse.files ?? []) keepAround(file.directory)
+ for (const database of browse.databases) keepAround(database.directory)
+ for (const folder of browse.folders) {
+ if (!spine.has(folder.directory)) keepAround(folder.directory)
+ }
+ }
+
+ const hidden = new Set(periodic)
+ for (const dir of spine) if (!kept.has(dir)) hidden.add(dir)
+ return hidden
+}
diff --git a/src/ui-mobile/widget-links.test.ts b/src/ui-mobile/widget-links.test.ts
index 0c4e2d3..8afae2b 100644
--- a/src/ui-mobile/widget-links.test.ts
+++ b/src/ui-mobile/widget-links.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
-import { isSafeVaultPath, parseWidgetLink } from './widget-links.ts'
+import { isIsoDay, isSafeVaultPath, parseWidgetLink } from './widget-links.ts'
test('the action links parse by host', () => {
assert.deepEqual(parseWidgetLink('zennotes://new'), { kind: 'new' })
@@ -57,3 +57,23 @@ test('other schemes, unknown actions, and the Cloud auth callback are not ours',
assert.equal(parseWidgetLink('not a url'), null)
assert.equal(parseWidgetLink(''), null)
})
+
+test('calendar opens on a real day, or on its own choice', () => {
+ assert.deepEqual(parseWidgetLink('zennotes://calendar'), { kind: 'calendar', date: null, add: false })
+ assert.deepEqual(parseWidgetLink('zennotes://calendar?date=2026-10-27&add=1'), {
+ kind: 'calendar',
+ date: '2026-10-27',
+ add: true
+ })
+ assert.deepEqual(parseWidgetLink('zennotes://calendar?date=2026-02-30'), { kind: 'calendar', date: null, add: false })
+})
+
+test('day and week need a real day', () => {
+ assert.deepEqual(parseWidgetLink('zennotes://day?date=2026-10-27'), { kind: 'day', date: '2026-10-27' })
+ assert.deepEqual(parseWidgetLink('zennotes://week?date=2026-10-26'), { kind: 'week', date: '2026-10-26' })
+ assert.equal(parseWidgetLink('zennotes://day'), null)
+ assert.equal(parseWidgetLink('zennotes://day?date=27-10-2026'), null)
+ assert.equal(parseWidgetLink('zennotes://week?date=2026-13-01'), null)
+ assert.equal(isIsoDay('2028-02-29'), true)
+ assert.equal(isIsoDay('2026-02-29'), false)
+})
diff --git a/src/ui-mobile/widget-links.ts b/src/ui-mobile/widget-links.ts
index 6e9971c..ad65ce4 100644
--- a/src/ui-mobile/widget-links.ts
+++ b/src/ui-mobile/widget-links.ts
@@ -7,6 +7,13 @@
* zennotes://task?id=&path= jump to a task line (VaultTask id)
* zennotes://tasks the Tasks view
* zennotes://home the Home dashboard
+ * zennotes://calendar[?date=&add=1]
+ * the Calendar sheet, on a day; add=1 puts
+ * the cursor in "Add a task for ..."
+ * zennotes://day?date= that day's daily note, else the Calendar on it
+ * zennotes://week?date= that day's weekly note, else the Calendar on it
+ *
+ * A is YYYY-MM-DD, a real calendar date.
*
* Values are percent-encoded with only unreserved characters left bare, so
* `#` (task ids are `path#index`) and `&` in titles survive the trip. The
@@ -19,6 +26,9 @@ export type WidgetLink =
| { kind: 'task'; id: string; path: string }
| { kind: 'tasks' }
| { kind: 'home' }
+ | { kind: 'calendar'; date: string | null; add: boolean }
+ | { kind: 'day'; date: string }
+ | { kind: 'week'; date: string }
/** Vault-relative, forward-slash, no traversal — the same shape the bridge's
* path guards accept, checked here so a bad link never throws. */
@@ -28,6 +38,14 @@ export function isSafeVaultPath(path: string): boolean {
return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
}
+/** `YYYY-MM-DD` naming a day that exists (no 2026-02-30). */
+export function isIsoDay(value: string | null): value is string {
+ if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
+ const [y, m, d] = value.split('-').map(Number)
+ const date = new Date(y, m - 1, d)
+ return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d
+}
+
export function parseWidgetLink(raw: string): WidgetLink | null {
let url: URL
try {
@@ -55,6 +73,19 @@ export function parseWidgetLink(raw: string): WidgetLink | null {
if (!id || !isSafeVaultPath(path)) return null
return { kind: 'task', id, path }
}
+ case 'calendar': {
+ const date = url.searchParams.get('date')
+ return {
+ kind: 'calendar',
+ date: isIsoDay(date) ? date : null,
+ add: url.searchParams.get('add') === '1'
+ }
+ }
+ case 'day':
+ case 'week': {
+ const date = url.searchParams.get('date')
+ return isIsoDay(date) ? { kind: action, date } : null
+ }
default:
return null
}
diff --git a/vendor/zennotes/manifest.json b/vendor/zennotes/manifest.json
index bd98661..7b316ba 100644
--- a/vendor/zennotes/manifest.json
+++ b/vendor/zennotes/manifest.json
@@ -1,12 +1,12 @@
{
"name": "@zennotes/app-core",
- "version": "2.77.0-core.hfe38f5ee2a70bd64",
- "file": "zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz",
- "sha256": "1103891fbbe1fc446cc1291af54116e5252e573c705f3766cdb7723d919f68f2",
- "integrity": "sha512-wZNZS/WkLLqi9DzWlKo5FYPl6Dpkk+C/viiiu7rhsryZARIrQodj5LDa9gTx3TUsIiDKFSg5XoP73ShmLuYsVA==",
- "sourceCommit": "3f0356de0d9351d9414ffd103cfd5d919c122cc3",
+ "version": "2.78.0-core.h0bc32cbd73a78fac",
+ "file": "zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz",
+ "sha256": "10949ac8983d4ac2947337392801bf91fc7f402c70afe066f9ccfba47ea17603",
+ "integrity": "sha512-Hq5qAJFFBJrJd67PxazwxgVPTZQ6K6bXkU9FoUILCywi4JcGR/hy60Klb5rClsO/Q+E8a/l6IbkE+T9TsUK4YA==",
+ "sourceCommit": "52ab86fdff9c4bb86ed3fb692b86a164e15fbbcf",
"workingTreeDirty": false,
- "sourceLockSha256": "359935dfa6dcf79d4f921e2026ed7368f7e389335f94dd4006c2ab12cbca3ea5",
+ "sourceLockSha256": "620d41fea1c82b1cac88a3ae73128ddf7fa04cac727d834c140be8f9a4b20997",
"toolchain": {
"node": "v22.23.3",
"typescript": "5.9.3",
@@ -15,20 +15,20 @@
"dependencies": [
{
"name": "@zennotes/bridge-contract",
- "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b",
- "file": "zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz",
- "sha256": "609267af04adaf76d7f7334d93153b03d817c8af3c1c80708f89a4d475af94a0",
- "integrity": "sha512-wMp1ytIf4U1v4ngHlJ7CegQfQPC1OhyQQXoM4O+tPXmaTWYIUsPHVhgs+i7sptraWQJJZ636Ybn27jDQHTIQ6Q==",
- "sourceCommit": "3f0356de0d9351d9414ffd103cfd5d919c122cc3",
+ "version": "2.78.0-boundaries.h328ca592d70ad8f4",
+ "file": "zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz",
+ "sha256": "30fd5633301728ada9dbbec60e69012eb93bc6696f7988503eb92fc237388a6f",
+ "integrity": "sha512-7Hm+frjf+iiQg9x0UXaZzZ0Wbbbg2aqbHb7W2iXGAaOEjvQXnxwzRv8vOGlPlf3KKsQvz8X0TRQ1TzGoOh/vAQ==",
+ "sourceCommit": "52ab86fdff9c4bb86ed3fb692b86a164e15fbbcf",
"workingTreeDirty": false
},
{
"name": "@zennotes/shared-domain",
- "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b",
- "file": "zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz",
- "sha256": "ef6be959cf0dfd3e309591bc7b9e1c453dd5851bf48f28c749f2d99da34ea025",
- "integrity": "sha512-x52yLX/DGx2tNN+3F5TBakphOnLq5TtXIhFM5prS1pp49i6IacXRo+ZZ9wmLr54zm4rqL6x2mfSbd4Dk3+fbJw==",
- "sourceCommit": "3f0356de0d9351d9414ffd103cfd5d919c122cc3",
+ "version": "2.78.0-boundaries.h328ca592d70ad8f4",
+ "file": "zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz",
+ "sha256": "2183aa36fc2d02825a7ead96de41fa40c9f09bd7be6369081307f00cd63b5132",
+ "integrity": "sha512-SlD18XyKoX0/68yPwZKUuitgsd3lzOLbsnC6vytvem/qfC3LdHDRWg4LaphxLO7h3bMChOLWUhd9MG36n5hldw==",
+ "sourceCommit": "52ab86fdff9c4bb86ed3fb692b86a164e15fbbcf",
"workingTreeDirty": false
}
]
diff --git a/vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz b/vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz
deleted file mode 100644
index e69ec2d..0000000
Binary files a/vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz and /dev/null differ
diff --git a/vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz b/vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz
new file mode 100644
index 0000000..fa60a0b
Binary files /dev/null and b/vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz differ
diff --git a/vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz b/vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz
deleted file mode 100644
index b4ca39e..0000000
Binary files a/vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz and /dev/null differ
diff --git a/vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz b/vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz
new file mode 100644
index 0000000..c9d192e
Binary files /dev/null and b/vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz differ
diff --git a/vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz b/vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz
deleted file mode 100644
index ac955ab..0000000
Binary files a/vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz and /dev/null differ
diff --git a/vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz b/vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz
new file mode 100644
index 0000000..1d61e34
Binary files /dev/null and b/vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz differ