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