mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 03:29:21 +02:00
Compare commits
1 Commits
core/fix-p
...
fix/xcode-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8a73a9e1 |
79
.github/workflows/help.preview.yml
vendored
79
.github/workflows/help.preview.yml
vendored
@@ -1,79 +0,0 @@
|
||||
name: Notesnook Help PR Preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
branches: [master, beta]
|
||||
paths:
|
||||
- "docs/help/**"
|
||||
# re-run workflow if workflow file changes
|
||||
- ".github/workflows/help.preview.yml"
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=help
|
||||
|
||||
- name: Build help
|
||||
run: npm run build:help
|
||||
|
||||
- name: Deploy to Cloudflare
|
||||
id: deploy
|
||||
working-directory: ./docs/help
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEPLOY_OUT=$(npx --yes wrangler versions upload 2>&1) || { echo "$DEPLOY_OUT"; exit 1; }
|
||||
echo "$DEPLOY_OUT"
|
||||
PREVIEW_URL=$(printf "%s" "$DEPLOY_OUT" | grep -Eo 'https?://[^ ]+' | head -1 || true)
|
||||
if [ -z "$PREVIEW_URL" ]; then
|
||||
echo "WARNING: could not parse preview URL from wrangler output"
|
||||
fi
|
||||
echo "preview_url=$PREVIEW_URL" >> $GITHUB_ENV
|
||||
|
||||
- name: Post or update PR comment
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
preview_url: ${{ env.preview_url }}
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- docs-pages-preview-comment -->';
|
||||
const prNumber = context.issue.number;
|
||||
const previewUrl = process.env.preview_url || '';
|
||||
const body = `${marker}\n**Cloudflare Pages Docs Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
27
.github/workflows/help.publish.yml
vendored
27
.github/workflows/help.publish.yml
vendored
@@ -2,6 +2,11 @@ name: Publish Notesnook Help
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
paths:
|
||||
- "docs/help/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -9,21 +14,17 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
# VitePress reads git history to show the last updated date per page.
|
||||
fetch-depth: 0
|
||||
toolchain: stable
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
- name: Install docgen
|
||||
run: cargo install --git https://github.com/thecodrr/docgen
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=help
|
||||
|
||||
- name: Build help
|
||||
run: npm run build:help
|
||||
- name: Build site
|
||||
run: docgen build --release
|
||||
working-directory: docs/help
|
||||
|
||||
- name: Setup environment
|
||||
run: |
|
||||
@@ -31,4 +32,4 @@ jobs:
|
||||
echo "CLOUDFLARE_API_TOKEN=${{ secrets.CLOUDFLARE_API_TOKEN }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Publish on Cloudflare Pages
|
||||
run: npx --yes wrangler deploy
|
||||
run: npx --yes wrangler pages deploy --project-name notesnook-help ./docs/help/site/ --branch main
|
||||
|
||||
4
.github/workflows/web.preview.yml
vendored
4
.github/workflows/web.preview.yml
vendored
@@ -27,9 +27,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=web
|
||||
run: npm ci
|
||||
|
||||
- name: Build web
|
||||
run: npm run build:web
|
||||
|
||||
@@ -65,7 +65,7 @@ We take all queries, issues and bug reports that you might have. Feel free to as
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Migrating & Importing your data from other apps — Importer](https://notesnook.com/help/importing-notes)
|
||||
- [Migrating & Importing your data from other apps — Importer](https://help.notesnook.com/importing-notes)
|
||||
- [Privacy policy](https://notesnook.com/privacy) & [Terms of service](https://notesnook.com/terms)
|
||||
- [Verify Notesnook encryption claims yourself — Vericrypt](https://vericrypt.notesnook.com/)
|
||||
- [Why Notesnook requires an email address?](https://blog.notesnook.com/why-notesnook-requires-an-email-address/)
|
||||
|
||||
@@ -203,12 +203,11 @@ module.exports = {
|
||||
toolsets: {
|
||||
appimage: "1.0.2"
|
||||
},
|
||||
snapcraft: {
|
||||
base: "core24",
|
||||
core24: {
|
||||
confinement: "strict",
|
||||
autoStart: false
|
||||
}
|
||||
snap: {
|
||||
autoStart: false,
|
||||
confinement: "strict",
|
||||
allowNativeWayland: true,
|
||||
base: "core22"
|
||||
},
|
||||
extraResources: ["app-update.yml", "./assets/**"],
|
||||
extraMetadata: {
|
||||
|
||||
@@ -140,7 +140,7 @@ android {
|
||||
if (project.hasProperty("prBuildNumber")) {
|
||||
versionCode Integer.parseInt(prBuildNumber())
|
||||
} else {
|
||||
versionCode 3114
|
||||
versionCode 3113
|
||||
}
|
||||
versionName getNpmVersion()
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
@@ -233,9 +233,6 @@ dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
|
||||
// Lets the widgets push their list data straight into the RemoteViews on every API level,
|
||||
// instead of the deprecated RemoteViewsService adapter (which needs API 31 to do natively).
|
||||
implementation("androidx.core:core-remoteviews:1.0.0")
|
||||
implementation("androidx.core:core-splashscreen:1.0.0")
|
||||
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
|
||||
@@ -86,9 +86,8 @@
|
||||
android:name=".NotePreviewWidget"
|
||||
android:exported="false"
|
||||
android:label="@string/note">
|
||||
<intent-filter>
|
||||
<intent-filter>"
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
<action android:name="android.appwidget.action.APPWIDGET_RESTORED" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
@@ -96,15 +95,6 @@
|
||||
android:resource="@xml/note_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".WidgetTimeChangeReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".ReminderWidgetProvider"
|
||||
android:exported="false"
|
||||
@@ -240,6 +230,11 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".ReminderViewsService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
|
||||
@@ -5,11 +5,15 @@ import android.appwidget.AppWidgetManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import com.facebook.react.ReactActivity;
|
||||
import com.facebook.react.ReactActivityDelegate;
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate;
|
||||
import com.google.gson.Gson;
|
||||
import com.streetwriters.notesnook.datatypes.Note;
|
||||
|
||||
public class NotePreviewConfigureActivity extends ReactActivity {
|
||||
|
||||
@@ -36,32 +40,18 @@ public class NotePreviewConfigureActivity extends ReactActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(null);
|
||||
activity = this;
|
||||
readAppWidgetId(getIntent());
|
||||
}
|
||||
|
||||
/**
|
||||
* We launch as singleTask, so configuring a second widget while this screen is still alive
|
||||
* arrives here rather than in onCreate(). Without this the activity would keep writing to
|
||||
* whichever widget it happened to be opened for first.
|
||||
*/
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
activity = this;
|
||||
readAppWidgetId(intent);
|
||||
}
|
||||
|
||||
private void readAppWidgetId(Intent intent) {
|
||||
Bundle extras = intent != null ? intent.getExtras() : null;
|
||||
int appWidgetId = extras != null
|
||||
? extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||
: AppWidgetManager.INVALID_APPWIDGET_ID;
|
||||
|
||||
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
|
||||
Intent intent = getIntent();
|
||||
Bundle extras = intent.getExtras();
|
||||
int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
|
||||
if (extras != null) {
|
||||
appWidgetId = extras.getInt(
|
||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||
AppWidgetManager.INVALID_APPWIDGET_ID);
|
||||
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
|
||||
}
|
||||
Intent resultValue = new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
||||
setResult(Activity.RESULT_CANCELED, resultValue);
|
||||
activity = this;
|
||||
}
|
||||
|
||||
public static void saveAndFinish(Context context) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.streetwriters.notesnook;
|
||||
|
||||
import android.app.ActivityOptions;
|
||||
import android.app.PendingIntent;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
@@ -7,125 +8,51 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.RemoteViews;
|
||||
import com.google.gson.Gson;
|
||||
import com.streetwriters.notesnook.datatypes.Note;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
public class NotePreviewWidget extends AppWidgetProvider {
|
||||
static String OpenNoteId = "com.streetwriters.notesnook.OpenNoteId";
|
||||
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
|
||||
int appWidgetId) {
|
||||
String data = context.getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
|
||||
|
||||
Note note = WidgetUtils.parseNote(data);
|
||||
if (note == null) {
|
||||
// Either the widget was never configured, or we lost the note it pointed at (ids
|
||||
// reassigned, data cleared). Point it back at the picker rather than leaving the user
|
||||
// with an inert widget they can only fix by deleting and re-adding it.
|
||||
views.setTextViewText(R.id.widget_title, context.getString(R.string.widget_note_unconfigured_title));
|
||||
views.setTextViewText(R.id.widget_body, context.getString(R.string.widget_note_unconfigured_body));
|
||||
views.setOnClickPendingIntent(R.id.open_note, getConfigurePendingIntent(context, appWidgetId));
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views);
|
||||
String data = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
|
||||
if (data.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Gson gson = new Gson();
|
||||
Note note = gson.fromJson(data, Note.class);
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
|
||||
views.setTextViewText(R.id.widget_title, note.getTitle());
|
||||
views.setTextViewText(R.id.widget_body, note.getHeadline());
|
||||
// Once the user shrinks the widget down to a single row there is no room for the preview
|
||||
// text, and a clipped half-line of it looks like a rendering glitch.
|
||||
views.setViewVisibility(R.id.widget_body,
|
||||
hasRoomForBody(appWidgetManager, appWidgetId) ? View.VISIBLE : View.GONE);
|
||||
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
intent.putExtra(OpenNoteId, note.getId());
|
||||
intent.setAction(Intent.ACTION_VIEW);
|
||||
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
|
||||
intent.setData(Uri.parse("nn://note/" + note.getId()));
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
|
||||
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);
|
||||
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopens the configure screen for this widget. The launcher's own "reconfigure" gesture is
|
||||
* hard to discover and not offered by every launcher, so an unconfigured widget needs its own
|
||||
* way back in.
|
||||
*/
|
||||
private static PendingIntent getConfigurePendingIntent(Context context, int appWidgetId) {
|
||||
Intent intent = new Intent(context, NotePreviewConfigureActivity.class);
|
||||
intent.setAction(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE);
|
||||
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
||||
// PendingIntent equality ignores extras, so the widget id has to be the request code for
|
||||
// each widget to get its own.
|
||||
return PendingIntent.getActivity(context, appWidgetId, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE,
|
||||
WidgetUtils.getActivityOptionsBundle());
|
||||
}
|
||||
|
||||
/**
|
||||
* Height below which the note preview text is dropped, leaving just the title.
|
||||
*/
|
||||
private static final int MIN_HEIGHT_FOR_BODY_DP = 70;
|
||||
|
||||
private static boolean hasRoomForBody(AppWidgetManager appWidgetManager, int appWidgetId) {
|
||||
Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId);
|
||||
if (options == null) return true;
|
||||
|
||||
int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
|
||||
// Not reported yet (the widget has just been placed): assume there is room.
|
||||
return minHeight <= 0 || minHeight >= MIN_HEIGHT_FOR_BODY_DP;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
|
||||
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
|
||||
// This used to do nothing at all, so resizing the widget left it rendered for its old size.
|
||||
updateAppWidget(context, appWidgetManager, appWidgetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The note shown by each widget is stored in the "appPreview" preferences under its widget id.
|
||||
* When the system restores our widgets it hands out fresh ids, so unless we move the stored
|
||||
* notes over to the new ids the widgets are left permanently blank with no way to recover
|
||||
* other than removing and re-adding them.
|
||||
*
|
||||
* AppWidgetProvider calls onUpdate() with the new ids right after this, which re-renders them.
|
||||
*/
|
||||
@Override
|
||||
public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) {
|
||||
super.onRestored(context, oldWidgetIds, newWidgetIds);
|
||||
if (oldWidgetIds == null || newWidgetIds == null) return;
|
||||
|
||||
int count = Math.min(oldWidgetIds.length, newWidgetIds.length);
|
||||
SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
||||
|
||||
// Read everything up front: an old id can collide with the new id of another widget.
|
||||
String[] notes = new String[count];
|
||||
Set<String> newKeys = new HashSet<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
notes[i] = preferences.getString(String.valueOf(oldWidgetIds[i]), "");
|
||||
newKeys.add(String.valueOf(newWidgetIds[i]));
|
||||
}
|
||||
|
||||
SharedPreferences.Editor edit = preferences.edit();
|
||||
for (int i = 0; i < count; i++) {
|
||||
String oldKey = String.valueOf(oldWidgetIds[i]);
|
||||
if (!newKeys.contains(oldKey)) {
|
||||
edit.remove(oldKey);
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (notes[i].isEmpty()) continue;
|
||||
edit.putString(String.valueOf(newWidgetIds[i]), notes[i]);
|
||||
}
|
||||
edit.apply();
|
||||
private static Bundle getActivityOptionsBundle() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ActivityOptions activityOptions = ActivityOptions.makeBasic();
|
||||
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
|
||||
return activityOptions.toBundle();
|
||||
} else
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.streetwriters.notesnook;
|
||||
|
||||
import android.app.ActivityOptions;
|
||||
import android.app.PendingIntent;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
@@ -22,10 +24,20 @@ public class NoteWidget extends AppWidgetProvider {
|
||||
|
||||
static void setClickIntent(Context context, RemoteViews views) {
|
||||
Intent intent = new Intent(context, ShareActivity.class);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
|
||||
views.setOnClickPendingIntent(R.id.new_note, pendingIntent);
|
||||
}
|
||||
|
||||
private static Bundle getActivityOptionsBundle() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ActivityOptions activityOptions = ActivityOptions.makeBasic();
|
||||
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
|
||||
return activityOptions.toBundle();
|
||||
} else
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
|
||||
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.drawable.Icon;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
@@ -28,6 +29,7 @@ import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.google.gson.Gson;
|
||||
import com.streetwriters.notesnook.datatypes.Note;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -136,7 +138,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
||||
if (Objects.equals(extras.getString(IntentType), "NewReminder")) {
|
||||
map.putString(ReminderWidgetProvider.NewReminder, extras.getString(ReminderWidgetProvider.NewReminder));
|
||||
} else if (Objects.equals(extras.getString(IntentType), "OpenReminder")) {
|
||||
map.putString(ReminderWidgetProvider.OpenReminderId, extras.getString(ReminderWidgetProvider.OpenReminderId));
|
||||
map.putString(ReminderViewsService.OpenReminderId, extras.getString(ReminderViewsService.OpenReminderId));
|
||||
} else if (Objects.equals(extras.getString(IntentType), "OpenNote")) {
|
||||
map.putString(NotePreviewWidget.OpenNoteId, extras.getString(NotePreviewWidget.OpenNoteId));
|
||||
}
|
||||
@@ -154,8 +156,14 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
||||
|
||||
@ReactMethod
|
||||
public void getWidgetNotes(Promise promise) {
|
||||
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
||||
Map<String, ?> map = pref.getAll();
|
||||
WritableArray arr = Arguments.createArray();
|
||||
for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
|
||||
for(Map.Entry<String,?> entry : map.entrySet()){
|
||||
if (entry.getKey().equals("remindersList")) continue;
|
||||
String value = (String) entry.getValue();
|
||||
Gson gson = new Gson();
|
||||
Note note = gson.fromJson(value, Note.class);
|
||||
arr.pushString(note.getId());
|
||||
}
|
||||
promise.resolve(arr);
|
||||
@@ -163,46 +171,36 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
||||
|
||||
@ReactMethod
|
||||
public void hasWidgetNote(final String noteId, Promise promise) {
|
||||
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
||||
Map<String, ?> map = pref.getAll();
|
||||
boolean found = false;
|
||||
for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
|
||||
if (note.getId().equals(noteId)) {
|
||||
for(Map.Entry<String,?> entry : map.entrySet()){
|
||||
String value = (String) entry.getValue();
|
||||
if (value.contains(noteId)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
promise.resolve(found);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void updateWidgetNote(final String noteId, final String data) {
|
||||
SharedPreferences pref = getReactApplicationContext().getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE);
|
||||
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
||||
Map<String, ?> map = pref.getAll();
|
||||
SharedPreferences.Editor edit = pref.edit();
|
||||
List<Integer> ids = new ArrayList<>();
|
||||
|
||||
// Match on the note's id, not on the raw JSON containing it somewhere: a note whose body
|
||||
// happens to mention another note's id is not the same note.
|
||||
for (Map.Entry<Integer, Note> entry : WidgetUtils.getWidgetNotes(getReactApplicationContext()).entrySet()) {
|
||||
if (!noteId.equals(entry.getValue().getId())) continue;
|
||||
edit.putString(String.valueOf(entry.getKey()), data);
|
||||
ids.add(entry.getKey());
|
||||
ArrayList<String> ids = new ArrayList<>();
|
||||
for(Map.Entry<String,?> entry : map.entrySet()) {
|
||||
String value = (String) entry.getValue();
|
||||
if (value.contains(noteId)) {
|
||||
edit.putString(entry.getKey(), data);
|
||||
ids.add(entry.getKey());
|
||||
}
|
||||
}
|
||||
edit.apply();
|
||||
|
||||
for (int id : ids) {
|
||||
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), id);
|
||||
for (String id: ids) {
|
||||
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), Integer.parseInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws every widget from scratch. Needed because the app can be stopped while its widgets
|
||||
* stay on the home screen: clearing app data empties the store without the widgets ever being
|
||||
* told, so they keep showing content that is gone until something forces a redraw.
|
||||
*/
|
||||
@ReactMethod
|
||||
public void refreshWidgets() {
|
||||
WidgetUtils.refreshAll(mContext);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void updateReminderWidget() {
|
||||
AppWidgetManager wm = AppWidgetManager.getInstance(mContext);
|
||||
@@ -210,8 +208,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
||||
for (int id: ids) {
|
||||
Log.d("Reminders", "Updating" + id);
|
||||
RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget_reminders);
|
||||
// The rows are part of this update, so there is nothing left to invalidate afterwards.
|
||||
ReminderWidgetProvider.updateAppWidget(mContext, wm, id, views);
|
||||
wm.notifyAppWidgetViewDataChanged(id, R.id.widget_list_view);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.streetwriters.notesnook;
|
||||
|
||||
import android.app.ActivityOptions;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.widget.RemoteViewsService;
|
||||
import android.content.Context;
|
||||
import android.widget.RemoteViews;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.streetwriters.notesnook.datatypes.Reminder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ReminderViewsService extends RemoteViewsService {
|
||||
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
|
||||
@Override
|
||||
public RemoteViewsFactory onGetViewFactory(Intent intent) {
|
||||
return new ReminderRemoteViewsFactory(this.getApplicationContext(), intent);
|
||||
}
|
||||
}
|
||||
|
||||
class ReminderRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
|
||||
private Context context;
|
||||
private List<Reminder> reminders;
|
||||
|
||||
public ReminderRemoteViewsFactory(Context context, Intent intent) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
// Initialize reminders list
|
||||
reminders = new ArrayList<Reminder>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDataSetChanged() {
|
||||
reminders.clear();
|
||||
SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE);
|
||||
Gson gson = new Gson();
|
||||
reminders = gson.fromJson(preferences.getString("remindersList","[]"), new TypeToken<List<Reminder>>(){}.getType());
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
reminders.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return reminders.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RemoteViews getViewAt(int position) {
|
||||
Reminder reminder = reminders.get(position);
|
||||
|
||||
boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty();
|
||||
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(), useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout);
|
||||
|
||||
views.setTextViewText(R.id.reminder_title, reminder.getTitle());
|
||||
if (!useMiniLayout) {
|
||||
views.setTextViewText(R.id.reminder_description, reminder.getDescription());
|
||||
}
|
||||
views.setTextViewText(R.id.reminder_time, reminder.getFormattedTime());
|
||||
final Intent fillInIntent = new Intent();
|
||||
final Bundle extras = new Bundle();
|
||||
extras.putString(ReminderViewsService.OpenReminderId, reminder.getId());
|
||||
fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId()));
|
||||
fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder");
|
||||
fillInIntent.putExtras(extras);
|
||||
views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent);
|
||||
return views;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public RemoteViews getLoadingView() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getViewTypeCount() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasStableIds() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,18 @@
|
||||
package com.streetwriters.notesnook;
|
||||
|
||||
import android.app.ActivityOptions;
|
||||
import android.app.PendingIntent;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.appwidget.AppWidgetProvider;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import androidx.core.widget.RemoteViewsCompat;
|
||||
|
||||
import com.streetwriters.notesnook.datatypes.Reminder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ReminderWidgetProvider extends AppWidgetProvider {
|
||||
static String NewReminder = "com.streetwriters.notesnook.NewReminder";
|
||||
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
|
||||
|
||||
@Override
|
||||
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
|
||||
@@ -27,10 +23,20 @@ public class ReminderWidgetProvider extends AppWidgetProvider {
|
||||
}
|
||||
|
||||
|
||||
private static Bundle getActivityOptionsBundle() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ActivityOptions activityOptions = ActivityOptions.makeBasic();
|
||||
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
|
||||
return activityOptions.toBundle();
|
||||
} else
|
||||
return null;
|
||||
}
|
||||
|
||||
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId, RemoteViews views) {
|
||||
Intent listview_intent_template = new Intent(context, MainActivity.class);
|
||||
listview_intent_template.setAction(Intent.ACTION_VIEW);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, WidgetUtils.getActivityOptionsBundle());
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, getActivityOptionsBundle());
|
||||
views.setPendingIntentTemplate(R.id.widget_list_view, pendingIntent);
|
||||
|
||||
Intent new_reminder_intent = new Intent(context, MainActivity.class);
|
||||
@@ -38,31 +44,13 @@ public class ReminderWidgetProvider extends AppWidgetProvider {
|
||||
new_reminder_intent.setAction(Intent.ACTION_VIEW);
|
||||
new_reminder_intent.putExtra(RCTNNativeModule.IntentType, "NewReminder");
|
||||
new_reminder_intent.setData(Uri.parse("https://app.notesnook.com/new_reminder"));
|
||||
PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
|
||||
PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
|
||||
views.setOnClickPendingIntent(R.id.add_button, pendingIntent2);
|
||||
|
||||
// The rows travel with the update itself, so there is no bound service to keep in sync and
|
||||
// nothing to invalidate separately: every update redraws from the current data.
|
||||
List<Reminder> reminders = WidgetUtils.getWidgetReminders(context);
|
||||
RemoteViewsCompat.RemoteCollectionItems.Builder items =
|
||||
new RemoteViewsCompat.RemoteCollectionItems.Builder();
|
||||
for (Reminder reminder : reminders) {
|
||||
items.addItem(getItemId(reminder), WidgetUtils.createReminderItem(context, reminder));
|
||||
}
|
||||
// Two, because a reminder without a description uses the compact row layout.
|
||||
items.setViewTypeCount(2);
|
||||
items.setHasStableIds(true);
|
||||
|
||||
RemoteViewsCompat.setRemoteAdapter(context, views, appWidgetId, R.id.widget_list_view, items.build());
|
||||
Intent list_remote_adapter_intent = new Intent(context, ReminderViewsService.class);
|
||||
list_remote_adapter_intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
|
||||
views.setRemoteAdapter(R.id.widget_list_view, list_remote_adapter_intent);
|
||||
views.setEmptyView(R.id.widget_list_view, R.id.empty_view);
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ties a row to its reminder rather than to its position, so rows keep their identity when the
|
||||
* list shifts around them.
|
||||
*/
|
||||
private static long getItemId(Reminder reminder) {
|
||||
return reminder.getId() == null ? 0 : reminder.getId().hashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.streetwriters.notesnook;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
/**
|
||||
* Redraws the widgets when the clock or timezone changes.
|
||||
*
|
||||
* Reminder rows are described relative to the current time ("Upcoming"/"Last", "Today"/"Tomorrow"),
|
||||
* and a reminder that has passed drops off the list entirely. All of that is decided when the
|
||||
* widget is drawn, so moving the clock leaves the previous drawing in place: a reminder can still
|
||||
* read "Upcoming" long after its time has gone by.
|
||||
*
|
||||
* The app normally redraws the list when a reminder notification is delivered, but that does not
|
||||
* happen if the reminder never fires, which is exactly the case when the clock jumps past it.
|
||||
*
|
||||
* Only TIME_SET and TIMEZONE_CHANGED are handled here: DATE_CHANGED is not exempt from the
|
||||
* Android 8 limits on manifest-registered implicit broadcasts, so a receiver for it would never
|
||||
* run. Crossing midnight is instead picked up by the widget's own periodic update.
|
||||
*/
|
||||
public class WidgetTimeChangeReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
WidgetUtils.refreshAll(context);
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
package com.streetwriters.notesnook;
|
||||
|
||||
import android.app.ActivityOptions;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.format.DateUtils;
|
||||
import android.util.Log;
|
||||
import android.widget.RemoteViews;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.streetwriters.notesnook.datatypes.Note;
|
||||
import com.streetwriters.notesnook.datatypes.Reminder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Shared helpers for the home screen widgets.
|
||||
*/
|
||||
public class WidgetUtils {
|
||||
|
||||
static final String PREFERENCES = "appPreview";
|
||||
static final String REMINDERS_KEY = "remindersList";
|
||||
|
||||
/**
|
||||
* Every row is serialized into the widget update itself, which has to fit inside a binder
|
||||
* transaction, so the list cannot grow without bound. Far more than fits on screen anyway.
|
||||
*/
|
||||
private static final int MAX_REMINDERS = 50;
|
||||
|
||||
/**
|
||||
* Redraws every widget that currently exists, and drops stored notes for widgets that no
|
||||
* longer do.
|
||||
*
|
||||
* Everything else keys off what we have stored, which is fine while the app is running but
|
||||
* leaves widgets showing content that no longer exists once the store is emptied underneath
|
||||
* them (clearing app data) or a widget is removed while the app is stopped (onDeleted never
|
||||
* arrives). Starting from the widgets the system knows about, rather than from our own data,
|
||||
* is what makes this self-correcting.
|
||||
*
|
||||
* NoteWidget is left alone deliberately: it is a static button with no stored state, and its
|
||||
* layout depends on the size it was last given.
|
||||
*/
|
||||
static void refreshAll(Context context) {
|
||||
AppWidgetManager manager = AppWidgetManager.getInstance(context);
|
||||
|
||||
int[] noteWidgetIds = manager.getAppWidgetIds(
|
||||
new ComponentName(context, NotePreviewWidget.class));
|
||||
removeOrphanedNotes(context, noteWidgetIds);
|
||||
for (int appWidgetId : noteWidgetIds) {
|
||||
NotePreviewWidget.updateAppWidget(context, manager, appWidgetId);
|
||||
}
|
||||
|
||||
for (int appWidgetId : manager.getAppWidgetIds(
|
||||
new ComponentName(context, ReminderWidgetProvider.class))) {
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_reminders);
|
||||
ReminderWidgetProvider.updateAppWidget(context, manager, appWidgetId, views);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops stored notes whose widget is gone, so the preferences file cannot grow forever.
|
||||
*/
|
||||
private static void removeOrphanedNotes(Context context, int[] liveWidgetIds) {
|
||||
Set<String> live = new HashSet<>();
|
||||
for (int appWidgetId : liveWidgetIds) live.add(String.valueOf(appWidgetId));
|
||||
|
||||
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor edit = preferences.edit();
|
||||
boolean changed = false;
|
||||
|
||||
for (String key : preferences.getAll().keySet()) {
|
||||
// Leave anything that is not a widget id alone, the reminders list included.
|
||||
if (parseWidgetId(key) == null || live.contains(key)) continue;
|
||||
edit.remove(key);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) edit.apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* The note each note widget is showing, keyed by widget id.
|
||||
*
|
||||
* The preferences file mixes two things: one note per widget id, and the reminders list under
|
||||
* its own key. Only numeric keys are widget notes, so anything else is skipped rather than
|
||||
* being treated as a note.
|
||||
*/
|
||||
static Map<Integer, Note> getWidgetNotes(Context context) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
|
||||
Map<Integer, Note> notes = new LinkedHashMap<>();
|
||||
|
||||
for (Map.Entry<String, ?> entry : preferences.getAll().entrySet()) {
|
||||
Integer widgetId = parseWidgetId(entry.getKey());
|
||||
if (widgetId == null) continue;
|
||||
if (!(entry.getValue() instanceof String)) continue;
|
||||
|
||||
Note note = parseNote((String) entry.getValue());
|
||||
if (note == null || note.getId() == null) continue;
|
||||
notes.put(widgetId, note);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget id a preferences key refers to, or null if the key is not a widget id at all.
|
||||
*/
|
||||
private static Integer parseWidgetId(String key) {
|
||||
try {
|
||||
return Integer.valueOf(key);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Note parseNote(String data) {
|
||||
if (data == null || data.isEmpty()) return null;
|
||||
try {
|
||||
return new Gson().fromJson(data, Note.class);
|
||||
} catch (Exception e) {
|
||||
Log.e("NotePreviewWidget", "Could not read a stored note", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminders the app last wrote out, minus any that have now dropped out of view. Reading
|
||||
* and filtering happens here so the provider can push the rows straight into the widget.
|
||||
*/
|
||||
static List<Reminder> getWidgetReminders(Context context) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
|
||||
List<Reminder> stored = null;
|
||||
try {
|
||||
stored = new Gson().fromJson(preferences.getString(REMINDERS_KEY, "[]"),
|
||||
new TypeToken<List<Reminder>>() {}.getType());
|
||||
} catch (Exception e) {
|
||||
Log.e("Reminders", "Could not read the stored reminders list", e);
|
||||
}
|
||||
|
||||
List<Reminder> active = new ArrayList<>();
|
||||
if (stored == null) return active;
|
||||
|
||||
for (Reminder reminder : stored) {
|
||||
if (!isVisibleInWidget(reminder)) continue;
|
||||
if (active.size() >= MAX_REMINDERS) {
|
||||
Log.w("Reminders", "Widget list truncated to " + MAX_REMINDERS + " reminders");
|
||||
break;
|
||||
}
|
||||
active.add(reminder);
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a single row of the reminders list.
|
||||
*/
|
||||
static RemoteViews createReminderItem(Context context, Reminder reminder) {
|
||||
boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty();
|
||||
|
||||
RemoteViews views = new RemoteViews(context.getPackageName(),
|
||||
useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout);
|
||||
|
||||
views.setTextViewText(R.id.reminder_title, reminder.getTitle());
|
||||
if (!useMiniLayout) {
|
||||
views.setTextViewText(R.id.reminder_description, reminder.getDescription());
|
||||
}
|
||||
views.setTextViewText(R.id.reminder_time, formatReminderTime(context, reminder));
|
||||
|
||||
Intent fillInIntent = new Intent();
|
||||
fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId()));
|
||||
fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder");
|
||||
fillInIntent.putExtra(ReminderWidgetProvider.OpenReminderId, reminder.getId());
|
||||
views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent);
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options attached to the PendingIntents our widgets hand to the launcher, opting the creator
|
||||
* (us) in to background activity starts so a tap on the widget can bring up an activity.
|
||||
*
|
||||
* MODE_BACKGROUND_ACTIVITY_START_ALLOWED is deprecated since API 36 and Android 17 extends the
|
||||
* background activity launch restrictions to IntentSender, so on API 36+ we use the narrower
|
||||
* MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE instead. That is enough for widgets: the
|
||||
* sender is the launcher, which is visible whenever the user taps the widget.
|
||||
*/
|
||||
static Bundle getActivityOptionsBundle() {
|
||||
ActivityOptions activityOptions;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||
activityOptions = ActivityOptions.makeBasic();
|
||||
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE);
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
activityOptions = ActivityOptions.makeBasic();
|
||||
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
|
||||
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return activityOptions.toBundle();
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a reminder keeps its place in the list after going off, so the user can see that it
|
||||
* happened rather than watching it vanish. Must match RECENTLY_PASSED_WINDOW in
|
||||
* services/notifications.ts, which decides what gets written out in the first place.
|
||||
*/
|
||||
private static final long RECENTLY_PASSED_WINDOW_MS = TimeUnit.HOURS.toMillis(3);
|
||||
|
||||
/**
|
||||
* Whether a reminder should still be drawn.
|
||||
*
|
||||
* We re-check here rather than trusting the stored list because that list is only rewritten
|
||||
* while the app runs. This is what actually retires a reminder once its grace period is up:
|
||||
* every redraw re-evaluates it against the current time.
|
||||
*/
|
||||
static boolean isVisibleInWidget(Reminder reminder) {
|
||||
if (reminder == null) return false;
|
||||
if (reminder.isDisabled()) return false;
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (reminder.getSnoozeUntil() > now) return true;
|
||||
if (!"once".equals(reminder.getMode())) return true;
|
||||
|
||||
long triggerDate = reminder.getTriggerDate() > 0 ? reminder.getTriggerDate() : reminder.getDate();
|
||||
return triggerDate > now - RECENTLY_PASSED_WINDOW_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the label shown under a reminder. The app sends us the absolute trigger time plus the
|
||||
* parts that never change ("5:00 PM", "12-05-2026, 5:00 PM"); everything that depends on the
|
||||
* current time is decided here so it stays right as the widget redraws.
|
||||
*
|
||||
* Falls back to the pre-formatted string for lists written by an older version of the app.
|
||||
*/
|
||||
static String formatReminderTime(Context context, Reminder reminder) {
|
||||
long triggerDate = reminder.getTriggerDate();
|
||||
String timeOfDay = reminder.getFormattedTimeOfDay();
|
||||
if (triggerDate <= 0 || timeOfDay == null || timeOfDay.isEmpty()) {
|
||||
return reminder.getFormattedTime();
|
||||
}
|
||||
|
||||
if ("permanent".equals(reminder.getMode())) {
|
||||
return context.getString(R.string.reminder_ongoing);
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (reminder.getSnoozeUntil() > now) {
|
||||
return context.getString(R.string.reminder_snoozed_until, timeOfDay);
|
||||
}
|
||||
|
||||
String text;
|
||||
long dayOffset = daysFromToday(triggerDate, now);
|
||||
if (dayOffset == 0) {
|
||||
text = context.getString(R.string.reminder_today, timeOfDay);
|
||||
} else if (dayOffset == 1) {
|
||||
text = context.getString(R.string.reminder_tomorrow, timeOfDay);
|
||||
} else if (dayOffset == -1) {
|
||||
text = context.getString(R.string.reminder_yesterday, timeOfDay);
|
||||
} else {
|
||||
text = reminder.getFormattedDateTime();
|
||||
if (text == null || text.isEmpty()) return reminder.getFormattedTime();
|
||||
}
|
||||
|
||||
return context.getString(
|
||||
triggerDate <= now ? R.string.reminder_last : R.string.reminder_upcoming, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar days between two instants. Compares midnights rather than subtracting the raw
|
||||
* difference so that "tomorrow" is still tomorrow across a DST change or just before midnight.
|
||||
*/
|
||||
private static long daysFromToday(long time, long now) {
|
||||
long target = startOfDay(time);
|
||||
long today = startOfDay(now);
|
||||
return Math.round((target - today) / (double) DateUtils.DAY_IN_MILLIS);
|
||||
}
|
||||
|
||||
private static long startOfDay(long time) {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTimeInMillis(time);
|
||||
calendar.set(Calendar.HOUR_OF_DAY, 0);
|
||||
calendar.set(Calendar.MINUTE, 0);
|
||||
calendar.set(Calendar.SECOND, 0);
|
||||
calendar.set(Calendar.MILLISECOND, 0);
|
||||
return calendar.getTimeInMillis();
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,13 @@ package com.streetwriters.notesnook.datatypes;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Keep
|
||||
public class Reminder extends BaseItem {
|
||||
private String title;
|
||||
private String description;
|
||||
private String formattedTime;
|
||||
private String formattedTimeOfDay; // e.g. "5:00 PM"
|
||||
private String formattedDateTime; // e.g. "12-05-2026, 5:00 PM"
|
||||
private long triggerDate; // absolute time this reminder next fires
|
||||
private String priority; // "silent", "vibrate", "urgent"
|
||||
private long date;
|
||||
private String mode; // "repeat", "once", "permanent"
|
||||
@@ -108,27 +107,32 @@ public class Reminder extends BaseItem {
|
||||
this.formattedTime = formattedTime;
|
||||
}
|
||||
|
||||
public String getFormattedTimeOfDay() {
|
||||
return formattedTimeOfDay;
|
||||
}
|
||||
public String formatTime(long timeInMillis) {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long diff = timeInMillis - currentTime;
|
||||
|
||||
public void setFormattedTimeOfDay(String formattedTimeOfDay) {
|
||||
this.formattedTimeOfDay = formattedTimeOfDay;
|
||||
}
|
||||
|
||||
public String getFormattedDateTime() {
|
||||
return formattedDateTime;
|
||||
}
|
||||
|
||||
public void setFormattedDateTime(String formattedDateTime) {
|
||||
this.formattedDateTime = formattedDateTime;
|
||||
}
|
||||
|
||||
public long getTriggerDate() {
|
||||
return triggerDate;
|
||||
}
|
||||
|
||||
public void setTriggerDate(long triggerDate) {
|
||||
this.triggerDate = triggerDate;
|
||||
if (diff < TimeUnit.MINUTES.toMillis(1)) {
|
||||
return "in " + (diff / 1000) + " seconds";
|
||||
} else if (diff < TimeUnit.HOURS.toMillis(1)) {
|
||||
long minutes = TimeUnit.MILLISECONDS.toMinutes(diff);
|
||||
return "in " + minutes + " minute" + (minutes > 1 ? "s" : "");
|
||||
} else if (diff < TimeUnit.DAYS.toMillis(1)) {
|
||||
long hours = TimeUnit.MILLISECONDS.toHours(diff);
|
||||
return "in " + hours + " hour" + (hours > 1 ? "s" : "");
|
||||
} else if (diff < TimeUnit.DAYS.toMillis(2)) {
|
||||
return "tomorrow";
|
||||
} else if (diff < TimeUnit.DAYS.toMillis(7)) {
|
||||
long days = TimeUnit.MILLISECONDS.toDays(diff);
|
||||
return "in " + days + " day" + (days > 1 ? "s" : "");
|
||||
} else if (diff < TimeUnit.DAYS.toMillis(30)) {
|
||||
long weeks = TimeUnit.MILLISECONDS.toDays(diff) / 7;
|
||||
return "in " + weeks + " week" + (weeks > 1 ? "s" : "");
|
||||
} else if (diff < TimeUnit.DAYS.toMillis(365)) {
|
||||
long months = TimeUnit.MILLISECONDS.toDays(diff) / 30;
|
||||
return "in " + months + " month" + (months > 1 ? "s" : "");
|
||||
} else {
|
||||
long years = TimeUnit.MILLISECONDS.toDays(diff) / 365;
|
||||
return "in " + years + " year" + (years > 1 ? "s" : "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/background"/>
|
||||
<stroke android:width="0dp" android:color="#B1BCBE" />
|
||||
<corners android:radius="@dimen/widget_background_radius"/>
|
||||
<corners android:radius="10dp"/>
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
||||
@@ -2,7 +2,8 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent">
|
||||
android:background="@android:color/transparent"
|
||||
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent">
|
||||
android:background="@android:color/transparent"
|
||||
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -24,7 +25,7 @@
|
||||
android:textColor="@color/text"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:text="@string/widget_note_unconfigured_title" />
|
||||
android:text="Widget unconfigured" />
|
||||
<TextView
|
||||
android:id="@+id/widget_body"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -33,7 +34,7 @@
|
||||
android:layout_marginLeft="8dp"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="14sp"
|
||||
android:text="@string/widget_note_unconfigured_body" />
|
||||
android:text="Configure this widget to show a note here." />
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -1,41 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Shown in the widget picker only. Mirrors note_widget.xml, but with sample content, since the
|
||||
real layout has nothing to show until the widget has been configured.
|
||||
-->
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerHorizontal="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="@drawable/layout_bg"
|
||||
android:elevation="5dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="10dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:text="@string/widget_preview_note_title"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:text="@string/widget_preview_note_body"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -0,0 +1,8 @@
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:text="No upcoming reminders"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:gravity="center" />
|
||||
@@ -12,23 +12,22 @@
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:layout_gravity="center"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingTop="12dp"
|
||||
>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textSize="15sp"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@color/text"
|
||||
android:text="Reminders"/>
|
||||
android:text="Upcoming Reminders"/>
|
||||
|
||||
<ImageButton
|
||||
android:layout_width="25dp"
|
||||
android:layout_width="35dp"
|
||||
android:id="@+id/add_button"
|
||||
android:layout_height="25dp"
|
||||
android:layout_height="35dp"
|
||||
android:layout_alignParentRight="true"
|
||||
android:background="@drawable/ic_newnote" />
|
||||
</RelativeLayout>
|
||||
@@ -58,8 +57,7 @@
|
||||
android:layout_height="match_parent"
|
||||
android:textAlignment="center"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text"
|
||||
android:text="@string/widget_reminders_empty"/>
|
||||
android:text="Tap on + to add reminder"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Shown in the widget picker only. Mirrors widget_reminders.xml, but with the list replaced by
|
||||
sample rows, since an adapter-backed list renders empty in the picker.
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/layout_bg"
|
||||
android:orientation="vertical">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingTop="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="Reminders"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ImageButton
|
||||
android:layout_width="35dp"
|
||||
android:layout_height="35dp"
|
||||
android:layout_alignParentRight="true"
|
||||
android:background="@drawable/ic_newnote"
|
||||
android:contentDescription="@string/add_widget" />
|
||||
</RelativeLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="@color/border" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_reminder_title"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:text="@string/widget_preview_reminder_time"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_reminder_title_alt"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/widget_preview_reminder_time_alt"
|
||||
android:textColor="@color/text"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="light_blue_50">#FFE1F5FE</color>
|
||||
<color name="light_blue_200">#FF81D4FA</color>
|
||||
<color name="light_blue_600">#FF039BE5</color>
|
||||
<color name="light_blue_900">#FF01579B</color>
|
||||
<color name="bootsplash_background">#1f1f1f</color>
|
||||
<color name="background">#1D1D1D</color>
|
||||
<color name="border">#2E2E2E</color>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Android 12 onwards the launcher tells us what radius widgets should use, so ours line up
|
||||
with every other widget on the home screen instead of being a fixed 10dp. -->
|
||||
<dimen name="widget_background_radius">@android:dimen/system_app_widget_background_radius</dimen>
|
||||
|
||||
</resources>
|
||||
6
apps/mobile/android/app/src/main/res/values/attrs.xml
Normal file
6
apps/mobile/android/app/src/main/res/values/attrs.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<declare-styleable name="AppWidgetAttrs">
|
||||
<attr name="appWidgetBackgroundColor" format="color" />
|
||||
<attr name="appWidgetTextColor" format="color" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="light_blue_50">#FFE1F5FE</color>
|
||||
<color name="light_blue_200">#FF81D4FA</color>
|
||||
<color name="light_blue_600">#FF039BE5</color>
|
||||
<color name="light_blue_900">#FF01579B</color>
|
||||
<color name="bootsplash_background">#FFFFFF</color>
|
||||
<color name="background">#DCEDEDED</color>
|
||||
<color name="border">#BFBFBF</color>
|
||||
|
||||
@@ -7,8 +7,4 @@ http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
|
||||
-->
|
||||
<dimen name="widget_margin">0dp</dimen>
|
||||
|
||||
<!-- Overridden in values-v31 with the platform's own widget radius, so our widgets match the
|
||||
rest of the home screen. This is the fallback for older versions. -->
|
||||
<dimen name="widget_background_radius">10dp</dimen>
|
||||
|
||||
</resources>
|
||||
@@ -4,28 +4,9 @@
|
||||
<string name="appwidget_text">EXAMPLE</string>
|
||||
<string name="add_widget">Add widget</string>
|
||||
<string name="take_a_quick_note">Take a quick note.</string>
|
||||
<string name="reminders">Quick overview of reminders</string>
|
||||
<string name="reminders">Quick overview of upcoming reminders</string>
|
||||
<string name="reminders_title">Reminders</string>
|
||||
<string name="note">Note</string>
|
||||
<string name="note_description">Add a note to home screen</string>
|
||||
<string name="quick_note">Quick note</string>
|
||||
<string name="widget_reminders_empty">Tap + to add a reminder</string>
|
||||
|
||||
<!-- Sample content, only ever shown in the widget picker's preview. -->
|
||||
<string name="widget_preview_note_title">Meeting notes</string>
|
||||
<string name="widget_preview_note_body">Discuss the roadmap and agree on timelines.</string>
|
||||
<string name="widget_preview_reminder_title">Take a walk</string>
|
||||
<string name="widget_preview_reminder_time">Upcoming: Today, 5:00 PM</string>
|
||||
<string name="widget_preview_reminder_title_alt">Call the dentist</string>
|
||||
<string name="widget_preview_reminder_time_alt">Upcoming: Tomorrow, 9:00 AM</string>
|
||||
|
||||
<string name="widget_note_unconfigured_title">Tap to choose a note</string>
|
||||
<string name="widget_note_unconfigured_body">Pick the note you want shown here.</string>
|
||||
<string name="reminder_ongoing">Ongoing</string>
|
||||
<string name="reminder_snoozed_until">Snoozed until %1$s</string>
|
||||
<string name="reminder_today">Today, %1$s</string>
|
||||
<string name="reminder_tomorrow">Tomorrow, %1$s</string>
|
||||
<string name="reminder_yesterday">Yesterday, %1$s</string>
|
||||
<string name="reminder_upcoming">Upcoming: %1$s</string>
|
||||
<string name="reminder_last">Last: %1$s</string>
|
||||
</resources>
|
||||
|
||||
7
apps/mobile/android/app/src/main/res/values/themes.xml
Normal file
7
apps/mobile/android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
|
||||
<style name="ThemeOverlay.Notesnook.AppWidgetContainer" parent="">
|
||||
<item name="appWidgetBackgroundColor">@color/light_blue_600</item>
|
||||
<item name="appWidgetTextColor">@color/light_blue_50</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Keep this in sync with res/xml/note_widget_info.xml. It exists only to add not_keyguard: from
|
||||
Android 16 QPR1 widgets are lock screen eligible by default, and this one renders the note's
|
||||
title and preview text, which should not be readable on a locked device.
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialKeyguardLayout="@layout/note_widget"
|
||||
android:initialLayout="@layout/note_widget"
|
||||
android:configure="com.streetwriters.notesnook.NotePreviewConfigureActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:minResizeWidth="100dp"
|
||||
android:minResizeHeight="50dp"
|
||||
android:minWidth="400dp"
|
||||
android:description="@string/note_description"
|
||||
android:minHeight="50dp"
|
||||
android:targetCellWidth="5"
|
||||
android:targetCellHeight="1"
|
||||
android:previewImage="@drawable/note_widget_preview"
|
||||
android:previewLayout="@layout/note_widget_preview"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="86400000"
|
||||
android:widgetCategory="home_screen|not_keyguard"/>
|
||||
@@ -1,15 +0,0 @@
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/widget_reminders"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:description="@string/reminders"
|
||||
android:targetCellWidth="5"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:previewImage="@drawable/reminder_preview"
|
||||
android:previewLayout="@layout/widget_reminders_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen|not_keyguard"
|
||||
/>
|
||||
@@ -10,7 +10,6 @@
|
||||
android:targetCellWidth="5"
|
||||
android:targetCellHeight="1"
|
||||
android:previewImage="@drawable/widget_preview"
|
||||
android:previewLayout="@layout/new_note_widget"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="86400000"
|
||||
android:widgetCategory="home_screen"/>
|
||||
@@ -12,7 +12,6 @@
|
||||
android:targetCellWidth="5"
|
||||
android:targetCellHeight="1"
|
||||
android:previewImage="@drawable/note_widget_preview"
|
||||
android:previewLayout="@layout/note_widget_preview"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="86400000"
|
||||
android:widgetCategory="home_screen"/>
|
||||
@@ -1,15 +1,14 @@
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/widget_reminders"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:minWidth="400dp"
|
||||
android:minHeight="100dp"
|
||||
android:minResizeWidth="400dp"
|
||||
android:minResizeHeight="50dp"
|
||||
android:description="@string/reminders"
|
||||
android:targetCellWidth="5"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:previewImage="@drawable/reminder_preview"
|
||||
android:previewLayout="@layout/widget_reminders_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:updatePeriodMillis="1024"
|
||||
android:widgetCategory="home_screen"
|
||||
/>
|
||||
@@ -13,7 +13,6 @@ buildscript {
|
||||
androidXCore = "1.7.0"
|
||||
androidXBrowser = "1.0.0"
|
||||
ndkVersion = "27.1.12297006"
|
||||
playBillingSdkVersion = "8.0.0"
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
- Added sync status icon in sidebar
|
||||
- Added new reminder shortcut in app icon context menu
|
||||
- Improved editor saving reliability
|
||||
- Add option to clear note version history
|
||||
- Minor bug fixes and improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
THEME_COMPATIBILITY_VERSION,
|
||||
useThemeEngineStore
|
||||
} from "@notesnook/theme";
|
||||
import React, { PropsWithChildren, useEffect, useState } from "react";
|
||||
import React, { PropsWithChildren, useEffect } from "react";
|
||||
import { Appearance, I18nManager, Linking, StatusBar } from "react-native";
|
||||
import "react-native-gesture-handler";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
@@ -44,37 +44,26 @@ import { useUserStore } from "./stores/use-user-store";
|
||||
import RNBootSplash from "react-native-bootsplash";
|
||||
import AppLocked from "./components/app-lock";
|
||||
import { useSettingStore } from "./stores/use-setting-store";
|
||||
import {
|
||||
initShortcutListener,
|
||||
launchNewNoteTab,
|
||||
registerAppShortcuts
|
||||
} from "./hooks/use-shortcut-manager";
|
||||
import Shortcuts from "react-native-actions-shortcuts";
|
||||
I18nManager.allowRTL(false);
|
||||
I18nManager.forceRTL(false);
|
||||
I18nManager.swapLeftAndRightInRTL(false);
|
||||
|
||||
const { appLockEnabled, appLockMode } = SettingsService.get();
|
||||
if (appLockEnabled || appLockMode !== "none") {
|
||||
useUserStore.getState().lockApp(true);
|
||||
}
|
||||
|
||||
RNBootSplash.hide({
|
||||
fade: true
|
||||
});
|
||||
Linking.getInitialURL().then((url) => {
|
||||
useSettingStore.setState({
|
||||
initialUrl: url
|
||||
});
|
||||
});
|
||||
const App = (props: { configureMode: "note-preview" }) => {
|
||||
useAppEvents();
|
||||
//@ts-ignore
|
||||
globalThis["IS_MAIN_APP_RUNNING"] = true;
|
||||
const introCompleted = useSettingStore(
|
||||
(state) => state.settings.introCompleted
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (introCompleted) {
|
||||
registerAppShortcuts();
|
||||
}
|
||||
}, [introCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
RNBootSplash.hide({ fade: true });
|
||||
SettingsService.onFirstLaunch();
|
||||
changeSystemBarColors();
|
||||
SettingsService.setPrivacyScreen(
|
||||
@@ -187,41 +176,4 @@ export const withTheme = (
|
||||
};
|
||||
};
|
||||
|
||||
export const withStartupBoundry = (
|
||||
Element: (props: PropsWithChildren) => JSX.Element
|
||||
) => {
|
||||
return function AppWithStartupBoundary(props: PropsWithChildren) {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
const [url, shortcut] = await Promise.all([
|
||||
Linking.getInitialURL(),
|
||||
Shortcuts.getInitialShortcut()
|
||||
]);
|
||||
console.log(url, shortcut);
|
||||
if (shortcut?.type === "notesnook.action.newnote") {
|
||||
launchNewNoteTab();
|
||||
}
|
||||
useSettingStore.setState({
|
||||
initialUrl: url,
|
||||
pendingShortcut: shortcut ?? null
|
||||
});
|
||||
|
||||
initShortcutListener();
|
||||
} finally {
|
||||
setReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return <Element {...props} />;
|
||||
};
|
||||
};
|
||||
|
||||
export default withStartupBoundry(withTheme(withErrorBoundry(App, "App")));
|
||||
export default withTheme(withErrorBoundry(App, "App"));
|
||||
|
||||
@@ -24,8 +24,6 @@ import { Button } from "../ui/button";
|
||||
import { IconButton } from "../ui/icon-button";
|
||||
import { hideAuth } from "./common";
|
||||
import { AuthParams } from "../../stores/use-navigation-store";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { presentDialog } from "../dialog/functions";
|
||||
export const AuthHeader = (props: { welcome?: boolean }) => {
|
||||
const { colors } = useThemeColors();
|
||||
const route = useRoute();
|
||||
@@ -58,17 +56,18 @@ export const AuthHeader = (props: { welcome?: boolean }) => {
|
||||
|
||||
{!props.welcome ? null : (
|
||||
<Button
|
||||
title={strings.skipAndGoToApp()}
|
||||
title="Skip"
|
||||
onPress={() => {
|
||||
presentDialog({
|
||||
title: strings.offlineMode(),
|
||||
paragraph: strings.offlineModeDesc(),
|
||||
positiveText: strings.understand(),
|
||||
positivePress: hideAuth as any
|
||||
});
|
||||
hideAuth();
|
||||
}}
|
||||
iconSize={16}
|
||||
type="plain"
|
||||
iconPosition="right"
|
||||
icon="chevron-right"
|
||||
height={25}
|
||||
iconStyle={{
|
||||
marginTop: 2
|
||||
}}
|
||||
style={{
|
||||
paddingHorizontal: 6
|
||||
}}
|
||||
|
||||
@@ -143,6 +143,9 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
}, [hide, show]);
|
||||
|
||||
const onNegativePress = async () => {
|
||||
if (dialogInfo?.onClose) {
|
||||
await dialogInfo.onClose();
|
||||
}
|
||||
hide();
|
||||
};
|
||||
|
||||
|
||||
@@ -51,10 +51,9 @@ interface TabProps extends ViewProps {
|
||||
onScroll: (offset: number) => void;
|
||||
enabled: boolean;
|
||||
onDrawerStateChange: (state: boolean) => void;
|
||||
initialPage?: FluidTabPage;
|
||||
}
|
||||
|
||||
export type FluidTabPage = "home" | "editor";
|
||||
type FluidTabPage = "home" | "editor";
|
||||
|
||||
export interface TabsRef {
|
||||
goToPage: (page: FluidTabPage, animated?: boolean) => void;
|
||||
@@ -78,21 +77,15 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
|
||||
onChangeTab,
|
||||
onScroll,
|
||||
enabled,
|
||||
onDrawerStateChange,
|
||||
initialPage
|
||||
onDrawerStateChange
|
||||
}: TabProps,
|
||||
ref
|
||||
) {
|
||||
const deviceMode = useSettingStore((state) => state.deviceMode);
|
||||
const fullscreen = useSettingStore((state) => state.fullscreen);
|
||||
const editorStartPosition = widths.sidebar + widths.list;
|
||||
const translateX = useSharedValue(
|
||||
initialPage === "editor" ? editorStartPosition : widths ? widths.sidebar : 0
|
||||
);
|
||||
const translateX = useSharedValue(widths ? widths.sidebar : 0);
|
||||
const startX = useSharedValue(0);
|
||||
const currentTab = useSharedValue(
|
||||
initialPage === "editor" && deviceMode !== "tablet" ? 2 : 1
|
||||
);
|
||||
const currentTab = useSharedValue(1);
|
||||
const previousTab = useSharedValue(1);
|
||||
const isDrawerOpen = useSharedValue(false);
|
||||
const gestureStartValue = useSharedValue({
|
||||
|
||||
@@ -58,9 +58,9 @@ export const openNote = async (
|
||||
}
|
||||
|
||||
if (isTrash) {
|
||||
const content = note.contentId
|
||||
? await db.content.get(note.contentId)
|
||||
: undefined;
|
||||
if (!note.contentId) return;
|
||||
|
||||
const content = await db.content.get(note.contentId as string);
|
||||
presentSheet({
|
||||
component: <NotePreview note={item} content={content} />
|
||||
});
|
||||
|
||||
@@ -249,7 +249,7 @@ export default function NoteHistory({
|
||||
<Text
|
||||
onPress={() => {
|
||||
openLinkInBrowser(
|
||||
"https://notesnook.com/help/note-version-history"
|
||||
"https://help.notesnook.com/note-version-history"
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function NotePreview({
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
height: 100,
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const Synced = ({ item, close }) => {
|
||||
close();
|
||||
await sleep(300);
|
||||
await openLinkInBrowser(
|
||||
"https://notesnook.com/help/how-is-my-data-encrypted",
|
||||
"https://help.notesnook.com/how-is-my-data-encrypted",
|
||||
colors
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -509,7 +509,7 @@ const PublishNoteSheet = ({
|
||||
onPress={async () => {
|
||||
try {
|
||||
await openLinkInBrowser(
|
||||
"https://notesnook.com/help/publish-notes-with-monographs"
|
||||
"https://help.notesnook.com/publish-notes-with-monographs"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -230,7 +230,7 @@ class RecoveryKeySheet extends React.Component {
|
||||
>
|
||||
<Paragraph
|
||||
color={colors.primary.paragraph}
|
||||
size={AppFontSize.md}
|
||||
size={AppFontSize.sm}
|
||||
numberOfLines={2}
|
||||
selectable
|
||||
style={{
|
||||
@@ -238,9 +238,7 @@ class RecoveryKeySheet extends React.Component {
|
||||
maxWidth: "100%",
|
||||
paddingRight: 10,
|
||||
textAlign: "center",
|
||||
textDecorationLine: "underline",
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: "monospace"
|
||||
textDecorationLine: "underline"
|
||||
}}
|
||||
>
|
||||
{this.state.key}
|
||||
|
||||
@@ -81,7 +81,6 @@ import {
|
||||
setUpdateAvailableMessage
|
||||
} from "../services/message";
|
||||
import Navigation from "../services/navigation";
|
||||
import { NotePreviewWidget } from "../services/note-preview-widget";
|
||||
import Notifications from "../services/notifications";
|
||||
import PremiumService from "../services/premium";
|
||||
import SettingsService from "../services/settings";
|
||||
@@ -576,11 +575,6 @@ export const useAppEvents = () => {
|
||||
useEffect(() => {
|
||||
if (isAppLoading) return;
|
||||
|
||||
// Widgets outlive the app process, so they can be left showing content the app no longer has
|
||||
// (most obviously after the user clears app data). Nothing can run at that moment, so the
|
||||
// first launch afterwards is the earliest chance to put them right.
|
||||
NotePreviewWidget.updateNotes();
|
||||
|
||||
let subscriptions: EventManagerSubscription[] = [];
|
||||
const eventManager = db.eventManager;
|
||||
subscriptions = [
|
||||
|
||||
@@ -17,67 +17,54 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import Shortcuts, { ShortcutItem } from "react-native-actions-shortcuts";
|
||||
import { NativeEventEmitter, NativeModule, Platform } from "react-native";
|
||||
import { useEffect } from "react";
|
||||
import { NativeEventEmitter, NativeModule } from "react-native";
|
||||
import { useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import deviceInfoModule from "react-native-device-info";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useSettingStore } from "../stores/use-setting-store";
|
||||
import { useTabStore } from "../screens/editor/tiptap/use-tab-store";
|
||||
|
||||
const ShortcutsEmitter = new NativeEventEmitter(
|
||||
Shortcuts as unknown as NativeModule
|
||||
);
|
||||
|
||||
export function isShortcutsSupported() {
|
||||
function isSupported() {
|
||||
return Platform.OS !== "android" || deviceInfoModule.getApiLevelSync() > 25;
|
||||
}
|
||||
|
||||
const defaultShortcuts: ShortcutItem[] = [
|
||||
{
|
||||
type: "notesnook.action.newnote",
|
||||
title: strings.createNewNote(),
|
||||
shortTitle: strings.newNote(),
|
||||
iconName: Platform.OS === "android" ? "ic_newnote" : "plus"
|
||||
},
|
||||
{
|
||||
type: "notesnook.action.newreminder",
|
||||
title: strings.setReminder(),
|
||||
shortTitle: strings.newReminder(),
|
||||
iconName: Platform.OS === "android" ? "ic_newnote" : "plus"
|
||||
}
|
||||
];
|
||||
export const useShortcutManager = ({
|
||||
onShortcutPressed,
|
||||
shortcuts = defaultShortcuts
|
||||
}: {
|
||||
onShortcutPressed: (shortcut: ShortcutItem | null) => void;
|
||||
shortcuts?: ShortcutItem[];
|
||||
}) => {
|
||||
const initialShortcutRecieved = useRef(false);
|
||||
|
||||
export function registerAppShortcuts(
|
||||
shortcuts: ShortcutItem[] = defaultShortcuts
|
||||
) {
|
||||
if (!isShortcutsSupported()) return;
|
||||
Shortcuts.setShortcuts(shortcuts);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!isSupported()) return;
|
||||
Shortcuts.setShortcuts(shortcuts);
|
||||
}, [shortcuts]);
|
||||
|
||||
let listenerInitialized = false;
|
||||
export function initShortcutListener() {
|
||||
if (!isShortcutsSupported() || listenerInitialized) return;
|
||||
listenerInitialized = true;
|
||||
ShortcutsEmitter.addListener(
|
||||
"onShortcutItemPressed",
|
||||
(shortcut: ShortcutItem) => {
|
||||
console.time("shortcut");
|
||||
useSettingStore.setState({ pendingShortcut: shortcut });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function launchNewNoteTab() {
|
||||
let tabId;
|
||||
const currentTab = useTabStore
|
||||
.getState()
|
||||
.getTab(useTabStore.getState().currentTab as string);
|
||||
|
||||
if (useTabStore.getState().tabs.length === 0 || currentTab?.pinned) {
|
||||
tabId = useTabStore.getState().newTab();
|
||||
} else {
|
||||
tabId = useTabStore.getState().currentTab;
|
||||
if (useTabStore.getState().getTab(tabId)?.session?.noteId) {
|
||||
useTabStore.getState().newTabSession(tabId, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!isSupported()) return;
|
||||
Shortcuts.getInitialShortcut().then((shortcut) => {
|
||||
if (initialShortcutRecieved.current || !shortcut) return;
|
||||
onShortcutPressed(shortcut);
|
||||
initialShortcutRecieved.current = true;
|
||||
});
|
||||
const subscription = ShortcutsEmitter.addListener(
|
||||
"onShortcutItemPressed",
|
||||
onShortcutPressed
|
||||
);
|
||||
return () => {
|
||||
subscription?.remove();
|
||||
};
|
||||
}, [onShortcutPressed]);
|
||||
};
|
||||
|
||||
@@ -41,9 +41,10 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { notesnook } from "../../e2e/test.ids";
|
||||
import { db } from "../common/database";
|
||||
import { FluidPanels, FluidTabPage } from "../components/fluid-panels";
|
||||
import { FluidPanels } from "../components/fluid-panels";
|
||||
import { useSideBarDraggingStore } from "../components/side-menu/dragging-store";
|
||||
import useGlobalSafeAreaInsets from "../hooks/use-global-safe-area-insets";
|
||||
import { useShortcutManager } from "../hooks/use-shortcut-manager";
|
||||
import { hideAllTooltips } from "../hooks/use-tooltip";
|
||||
import { useTabStore } from "../screens/editor/tiptap/use-tab-store";
|
||||
import { editorController, editorState } from "../screens/editor/tiptap/utils";
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
eCloseFullscreenEditor,
|
||||
eOnEnterEditor,
|
||||
eOnExitEditor,
|
||||
eOnLoadNote,
|
||||
eOpenFullscreenEditor,
|
||||
eUnlockNote
|
||||
} from "../utils/events";
|
||||
@@ -65,7 +67,6 @@ import { valueLimiter } from "../utils/functions";
|
||||
import { fluidTabsRef } from "../utils/global-refs";
|
||||
import { AppNavigationStack } from "./navigation-stack";
|
||||
import type { PaneWidths } from "../screens/editor/wrapper";
|
||||
import { NavigationProps } from "../services/navigation";
|
||||
|
||||
const MOBILE_SIDEBAR_SIZE = 0.85;
|
||||
|
||||
@@ -73,7 +74,7 @@ let SideMenu: any = null;
|
||||
let EditorWrapper: any = null;
|
||||
|
||||
export const FluidPanelsView = React.memo(
|
||||
({ route }: NavigationProps<"FluidPanelsView">) => {
|
||||
() => {
|
||||
const { colors } = useThemeColors();
|
||||
const deviceMode = useSettingStore((state) => state.deviceMode);
|
||||
const setFullscreen = useSettingStore((state) => state.setFullscreen);
|
||||
@@ -101,15 +102,6 @@ export const FluidPanelsView = React.memo(
|
||||
setOrientation(o);
|
||||
}
|
||||
});
|
||||
React.useEffect(() => {
|
||||
const shortcut = useSettingStore.getState().pendingShortcut;
|
||||
|
||||
if (shortcut?.type === "notesnook.action.newnote") {
|
||||
useSettingStore.setState({
|
||||
pendingShortcut: null
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appLoading) {
|
||||
@@ -119,6 +111,29 @@ export const FluidPanelsView = React.memo(
|
||||
}
|
||||
}, [appLoading]);
|
||||
|
||||
useShortcutManager({
|
||||
onShortcutPressed: async (item) => {
|
||||
if (!item) return;
|
||||
|
||||
if (item?.type === "notesnook.action.newnote") {
|
||||
if (!fluidTabsRef.current) {
|
||||
setTimeout(() => {
|
||||
eSendEvent(eOnLoadNote, { newNote: true });
|
||||
editorState().movedAway = false;
|
||||
fluidTabsRef.current?.goToPage("editor", false);
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
eSendEvent(eOnLoadNote, { newNote: true });
|
||||
editorState().movedAway = false;
|
||||
setTimeout(
|
||||
() => fluidTabsRef.current?.goToPage("editor", false),
|
||||
300
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showFullScreenEditor = useCallback(() => {
|
||||
setFullscreen(true);
|
||||
if (deviceMode === "smallTablet") {
|
||||
@@ -341,7 +356,6 @@ export const FluidPanelsView = React.memo(
|
||||
dimensions={dimensions}
|
||||
widths={PANE_WIDTHS[deviceMode as keyof typeof PANE_WIDTHS]}
|
||||
enabled={deviceMode !== "tablet" && !fullscreen}
|
||||
initialPage={route.params?.initialPage}
|
||||
onScroll={onScroll}
|
||||
onChangeTab={onChangeTab}
|
||||
onDrawerStateChange={(state) => {
|
||||
|
||||
@@ -27,17 +27,10 @@ import useNavigationStore, {
|
||||
} from "../stores/use-navigation-store";
|
||||
import { useSelectionStore } from "../stores/use-selection-store";
|
||||
import { useSettingStore } from "../stores/use-setting-store";
|
||||
import { fluidTabsRef, rootNavigatorRef } from "../utils/global-refs";
|
||||
import { rootNavigatorRef } from "../utils/global-refs";
|
||||
import Navigation from "../services/navigation";
|
||||
import { isFeatureAvailable, useIsFeatureAvailable } from "@notesnook/common";
|
||||
import { isFeatureAvailable } from "@notesnook/common";
|
||||
import { isInternalLink, parseInternalLink } from "@notesnook/core";
|
||||
import { eSendEvent } from "../services/event-manager";
|
||||
import { editorState } from "../screens/editor/tiptap/utils";
|
||||
import { eOnLoadNote } from "../utils/events";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import PaywallSheet from "../components/sheets/paywall";
|
||||
import { presentDialog } from "../components/dialog/functions";
|
||||
import { launchNewNoteTab } from "../hooks/use-shortcut-manager";
|
||||
|
||||
const RootStack = createNativeStackNavigator();
|
||||
const AppStack = createNativeStackNavigator();
|
||||
@@ -307,15 +300,8 @@ export const RootNavigation = () => {
|
||||
const introCompleted = useSettingStore(
|
||||
(state) => state.settings.introCompleted
|
||||
);
|
||||
|
||||
const initialShortcut = React.useRef(
|
||||
useSettingStore.getState().pendingShortcut
|
||||
).current;
|
||||
|
||||
const reminderFeature = useIsFeatureAvailable("activeReminders");
|
||||
const clearSelection = useSelectionStore((state) => state.clearSelection);
|
||||
const resetTimer = React.useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
const onStateChange = React.useCallback(
|
||||
(state: any) => {
|
||||
if (useSelectionStore.getState().selectionMode) {
|
||||
@@ -330,66 +316,13 @@ export const RootNavigation = () => {
|
||||
[clearSelection]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = useSettingStore.subscribe((state, prevState) => {
|
||||
const pendingShortcut = state.pendingShortcut;
|
||||
|
||||
if (pendingShortcut === prevState.pendingShortcut || !pendingShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingShortcut.type === "notesnook.action.newreminder") {
|
||||
if (reminderFeature === undefined) return;
|
||||
|
||||
if (!reminderFeature.isAllowed) {
|
||||
presentDialog({
|
||||
title: strings.upgrade(),
|
||||
paragraph: reminderFeature.error,
|
||||
positiveText: strings.upgrade(),
|
||||
negativeText: strings.cancel(),
|
||||
positivePress: async () => {
|
||||
PaywallSheet.present(reminderFeature);
|
||||
}
|
||||
});
|
||||
useSettingStore.setState({
|
||||
pendingShortcut: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
rootNavigatorRef.current?.navigate("AddReminder" as any);
|
||||
} else if (pendingShortcut.type === "notesnook.action.newnote") {
|
||||
if (fluidTabsRef.current) {
|
||||
rootNavigatorRef.current?.navigate("FluidPanelsView" as any);
|
||||
eSendEvent(eOnLoadNote, { newNote: true });
|
||||
editorState().movedAway = false;
|
||||
fluidTabsRef.current.goToPage("editor", true);
|
||||
} else {
|
||||
launchNewNoteTab();
|
||||
|
||||
rootNavigatorRef.current?.navigate("FluidPanelsView" as any, {
|
||||
initialPage: "editor"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [reminderFeature]);
|
||||
|
||||
const initialRouteName = !introCompleted
|
||||
? "Welcome"
|
||||
: initialShortcut?.type === "notesnook.action.newreminder"
|
||||
? "AddReminder"
|
||||
: "FluidPanelsView";
|
||||
|
||||
return (
|
||||
<NavigationContainer onStateChange={onStateChange} ref={rootNavigatorRef}>
|
||||
<RootStack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false
|
||||
}}
|
||||
initialRouteName={initialRouteName}
|
||||
initialRouteName={introCompleted ? "FluidPanelsView" : "Welcome"}
|
||||
>
|
||||
<RootStack.Screen
|
||||
name="Welcome"
|
||||
@@ -414,12 +347,6 @@ export const RootNavigation = () => {
|
||||
require("../navigation/fluid-panels-view").default;
|
||||
return FluidPanelsView;
|
||||
}}
|
||||
initialParams={{
|
||||
initialPage:
|
||||
initialShortcut?.type === "notesnook.action.newnote"
|
||||
? "editor"
|
||||
: undefined
|
||||
}}
|
||||
/>
|
||||
|
||||
<RootStack.Screen
|
||||
|
||||
@@ -20,9 +20,8 @@ import { Note, Reminder } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import {
|
||||
BackHandler,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
@@ -64,7 +63,6 @@ import FormInput, {
|
||||
validators
|
||||
} from "../../components/ui/input/form-input";
|
||||
import AppIcon from "../../components/ui/AppIcon";
|
||||
import { presentDialog } from "../../components/dialog/functions";
|
||||
|
||||
const ReminderModes =
|
||||
Platform.OS === "ios"
|
||||
@@ -96,7 +94,7 @@ const ReminderNotificationModes = {
|
||||
};
|
||||
|
||||
export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
const { reminder, reference } = props.route.params ?? {};
|
||||
const { reminder, reference } = props.route.params;
|
||||
useNavigationFocus(props.navigation, {
|
||||
focusOnInit: true,
|
||||
onFocus: () => {
|
||||
@@ -108,23 +106,6 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const handleBackNavigation = useCallback(() => {
|
||||
const routes = props.navigation.getState()?.routes;
|
||||
if (routes && routes.length <= 1) {
|
||||
props.navigation.navigate("FluidPanelsView" as any);
|
||||
return true;
|
||||
}
|
||||
Navigation.goBack();
|
||||
return true;
|
||||
}, [props.navigation]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
return handleBackNavigation();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [handleBackNavigation]);
|
||||
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const weekFormat = useSettingStore((state) => state.weekFormat);
|
||||
const [reminderMode, setReminderMode] = useState<Reminder["mode"]>(
|
||||
@@ -146,7 +127,6 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
const [repeatFrequency, setRepeatFrequency] = useState(1);
|
||||
const referencedItem = reference ? (reference as Note) : null;
|
||||
const recurringReminderFeature = useIsFeatureAvailable("recurringReminders");
|
||||
const activeReminderFeature = useIsFeatureAvailable("activeReminders");
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
title: reminder?.title || referencedItem?.title || "",
|
||||
@@ -173,31 +153,6 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
);
|
||||
const [dateError, setDateError] = useState<string>();
|
||||
const [selectDayError, setSelectDayError] = useState<string>();
|
||||
React.useEffect(() => {
|
||||
const shortcut = useSettingStore.getState().pendingShortcut;
|
||||
if (shortcut?.type === "notesnook.action.newreminder") {
|
||||
useSettingStore.setState({
|
||||
pendingShortcut: null
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (activeReminderFeature === undefined) return;
|
||||
if (!activeReminderFeature.isAllowed) {
|
||||
presentDialog({
|
||||
title: strings.upgrade(),
|
||||
paragraph: activeReminderFeature.error,
|
||||
positiveText: strings.upgrade(),
|
||||
negativeText: strings.cancel(),
|
||||
positivePress: async () => {
|
||||
PaywallSheet.present(activeReminderFeature);
|
||||
},
|
||||
onClose: () => {
|
||||
props.navigation.navigate("FluidPanelsView" as any);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [activeReminderFeature]);
|
||||
|
||||
const showDatePicker = () => {
|
||||
setDatePickerVisibility(true);
|
||||
@@ -288,7 +243,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
Notifications.scheduleNotification(_reminder as Reminder);
|
||||
Navigation.queueRoutesForUpdate();
|
||||
useRelationStore.getState().update();
|
||||
handleBackNavigation();
|
||||
Navigation.goBack();
|
||||
} catch (e) {
|
||||
ToastManager.error(e as Error, undefined);
|
||||
}
|
||||
@@ -312,12 +267,12 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
|
||||
<Header
|
||||
title={reminder ? strings.editReminder() : strings.newReminder()}
|
||||
canGoBack
|
||||
onLeftMenuButtonPress={handleBackNavigation}
|
||||
rightButton={{
|
||||
name: "check",
|
||||
onPress: saveReminder
|
||||
}}
|
||||
/>
|
||||
<Dialog context="local" />
|
||||
<ScrollView
|
||||
style={{
|
||||
marginBottom: DDS.isTab ? 25 : undefined,
|
||||
|
||||
@@ -90,10 +90,9 @@ export type SavePayload = {
|
||||
data?: string;
|
||||
type?: "tiptap";
|
||||
sessionHistoryId?: number;
|
||||
ignoreEdit: boolean;
|
||||
tabId: string;
|
||||
pendingChanges?: boolean;
|
||||
sourceNoteId?: string;
|
||||
pendingChangesAt?: number;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
|
||||
@@ -149,7 +149,7 @@ const showActionsheet = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
type ContentMessage = { html: string };
|
||||
type ContentMessage = { html: string; ignoreEdit: boolean };
|
||||
|
||||
export const useEditorEvents = (
|
||||
editor: useEditorType,
|
||||
@@ -411,19 +411,16 @@ export const useEditorEvents = (
|
||||
.getState()
|
||||
.getNoteIdForTab(editorMessage.tabId);
|
||||
|
||||
const saveNoteId = editorMessage.noteId || noteId;
|
||||
|
||||
switch (editorMessage.type) {
|
||||
case EditorEvents.content:
|
||||
DatabaseLogger.log("EditorEvents.content");
|
||||
editor.saveContent({
|
||||
type: editorMessage.type,
|
||||
content: editorMessage.value.html as string,
|
||||
noteId: saveNoteId,
|
||||
sourceNoteId: editorMessage.noteId,
|
||||
noteId: noteId,
|
||||
tabId: editorMessage.tabId,
|
||||
pendingChanges: editorMessage.value?.pendingChanges,
|
||||
pendingChangesAt: editorMessage.value?.pendingChangesAt
|
||||
ignoreEdit: (editorMessage.value as ContentMessage).ignoreEdit,
|
||||
pendingChanges: editorMessage.value?.pendingChanges
|
||||
});
|
||||
break;
|
||||
case EditorEvents.title:
|
||||
@@ -431,11 +428,10 @@ export const useEditorEvents = (
|
||||
editor.saveContent({
|
||||
type: editorMessage.type,
|
||||
title: editorMessage.value?.title as string,
|
||||
noteId: saveNoteId,
|
||||
sourceNoteId: editorMessage.noteId,
|
||||
noteId: noteId,
|
||||
tabId: editorMessage.tabId,
|
||||
pendingChanges: editorMessage.value?.pendingChanges,
|
||||
pendingChangesAt: editorMessage.value?.pendingChangesAt
|
||||
ignoreEdit: false,
|
||||
pendingChanges: editorMessage.value?.pendingChanges
|
||||
});
|
||||
break;
|
||||
case EditorEvents.logger:
|
||||
|
||||
@@ -260,65 +260,29 @@ export const useEditor = (
|
||||
id,
|
||||
data,
|
||||
type,
|
||||
ignoreEdit,
|
||||
sessionHistoryId: currentSessionHistoryId,
|
||||
tabId,
|
||||
pendingChanges,
|
||||
sourceNoteId,
|
||||
pendingChangesAt
|
||||
pendingChanges
|
||||
}: SavePayload) => {
|
||||
if (currentNotes.current[id as string]?.readonly || readonly) return;
|
||||
|
||||
if (sourceNoteId && id && sourceNoteId !== id) {
|
||||
DatabaseLogger.error(
|
||||
new Error(
|
||||
`Refused to save content of note ${sourceNoteId} into note ${id}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (id && !(await db.notes?.note(id))) {
|
||||
if (useTabStore.getState().getNoteIdForTab(tabId) === id) {
|
||||
await reset(tabId);
|
||||
useTabStore.getState().updateTab(tabId, {
|
||||
session: {
|
||||
noteId: undefined,
|
||||
noteLocked: undefined,
|
||||
locked: undefined,
|
||||
readonly: undefined,
|
||||
scrollTop: undefined,
|
||||
selection: undefined,
|
||||
spellCheckDisabled: false
|
||||
}
|
||||
});
|
||||
}
|
||||
await reset(tabId);
|
||||
useTabStore.getState().updateTab(tabId, {
|
||||
session: {
|
||||
noteId: undefined,
|
||||
noteLocked: undefined,
|
||||
locked: undefined,
|
||||
readonly: undefined,
|
||||
scrollTop: undefined,
|
||||
selection: undefined,
|
||||
spellCheckDisabled: false
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let note = id ? await db.notes?.note(id) : undefined;
|
||||
|
||||
// A restored pending change can be older than what is already in the
|
||||
// db (it was saved on another device, or the save actually went
|
||||
// through and only the acknowledgement was lost). Applying it would
|
||||
// roll the note back, so verify it is still the newest edit. Content
|
||||
// and title are compared separately so that a newer title doesn't
|
||||
// discard pending content, and vice versa.
|
||||
if (pendingChanges && pendingChangesAt && note) {
|
||||
const dateEdited = data
|
||||
? note.contentId
|
||||
? (await db.content?.get(note.contentId))?.dateEdited
|
||||
: undefined
|
||||
: note.dateEdited;
|
||||
|
||||
if (dateEdited && dateEdited > pendingChangesAt) {
|
||||
DatabaseLogger.log(
|
||||
`Discarding stale pending ${
|
||||
data ? "content" : "title"
|
||||
} for note ${id}: edited at ${dateEdited}, change captured at ${pendingChangesAt}`
|
||||
);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
const locked = note && (await db.vaults.itemExists(note));
|
||||
|
||||
if (note?.conflicted) {
|
||||
@@ -342,6 +306,11 @@ export const useEditor = (
|
||||
|
||||
noteData.title = title;
|
||||
|
||||
if (ignoreEdit) {
|
||||
DatabaseLogger.log("Ignoring edits...");
|
||||
noteData.dateEdited = note?.dateEdited;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
noteData.content = {
|
||||
data: data,
|
||||
@@ -352,9 +321,6 @@ export const useEditor = (
|
||||
let saved = false;
|
||||
setTimeout(() => {
|
||||
if (saved) return;
|
||||
// Don't report progress on a tab that has moved on to another note.
|
||||
if (id && useTabStore.getState().getNoteIdForTab(tabId) !== id)
|
||||
return;
|
||||
commands.setStatus(
|
||||
getFormattedDate(note ? note.dateEdited : Date.now(), "date-time"),
|
||||
strings.saving(),
|
||||
@@ -470,26 +436,14 @@ export const useEditor = (
|
||||
}
|
||||
}
|
||||
|
||||
if (id && pendingChanges) {
|
||||
if (data) {
|
||||
currentContents.current[id] = {
|
||||
data: data,
|
||||
type: "tiptap",
|
||||
noteId: id
|
||||
};
|
||||
}
|
||||
lastContentChangeTime.current[id] = Date.now();
|
||||
|
||||
// Push the restored change into the editor only if the note is
|
||||
// actually open in a tab, and only into that tab.
|
||||
const noteTabId = useTabStore.getState().getTabForNote(id);
|
||||
if (noteTabId !== undefined) {
|
||||
postMessage(NativeEvents.title, title || note?.title, noteTabId);
|
||||
if (data) {
|
||||
postMessage(NativeEvents.html, { data: data }, noteTabId);
|
||||
}
|
||||
currentNotes.current[id] = note;
|
||||
}
|
||||
if (
|
||||
id &&
|
||||
id === useTabStore.getState().getCurrentNoteId() &&
|
||||
pendingChanges
|
||||
) {
|
||||
postMessage(NativeEvents.title, title || note?.title, tabId);
|
||||
postMessage(NativeEvents.html, data, tabId);
|
||||
currentNotes.current[id] = note;
|
||||
}
|
||||
|
||||
if (!saveCount.current[tabId]) {
|
||||
@@ -978,25 +932,24 @@ export const useEditor = (
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
ignoreEdit,
|
||||
noteId,
|
||||
tabId,
|
||||
pendingChanges,
|
||||
sourceNoteId,
|
||||
pendingChangesAt
|
||||
pendingChanges
|
||||
}: {
|
||||
noteId?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
type: string;
|
||||
ignoreEdit: boolean;
|
||||
tabId: string;
|
||||
pendingChanges?: boolean;
|
||||
sourceNoteId?: string;
|
||||
pendingChangesAt?: number;
|
||||
}) => {
|
||||
DatabaseLogger.log(
|
||||
`saveContent... title: ${!!title}, content: ${!!content}, noteId: ${noteId}`
|
||||
);
|
||||
if (
|
||||
ignoreEdit ||
|
||||
lock.current ||
|
||||
(currentLoadingNoteId.current &&
|
||||
currentLoadingNoteId.current === noteId)
|
||||
@@ -1005,6 +958,7 @@ export const useEditor = (
|
||||
|
||||
lock.current: ${lock.current}
|
||||
currentLoadingNoteId.current: ${currentLoadingNoteId.current}
|
||||
ignoreEdit: ${ignoreEdit}
|
||||
`);
|
||||
if (lock.current) {
|
||||
setTimeout(() => {
|
||||
@@ -1017,10 +971,7 @@ export const useEditor = (
|
||||
return;
|
||||
}
|
||||
|
||||
// A restored pending change is not a live edit: it may still be
|
||||
// discarded as stale by saveNote, so it must not claim to be the newest
|
||||
// content until it is actually written.
|
||||
if (noteId && !pendingChanges) {
|
||||
if (noteId) {
|
||||
lastContentChangeTime.current[noteId] = Date.now();
|
||||
localTabState.current?.setEditTime(noteId, Date.now());
|
||||
localTabState?.current?.set(tabId, {
|
||||
@@ -1028,7 +979,7 @@ export const useEditor = (
|
||||
});
|
||||
}
|
||||
|
||||
if (type === EditorEvents.content && noteId && !pendingChanges) {
|
||||
if (type === EditorEvents.content && noteId) {
|
||||
currentContents.current[noteId as string] = {
|
||||
data: content,
|
||||
type: "tiptap",
|
||||
@@ -1041,17 +992,15 @@ export const useEditor = (
|
||||
data: content,
|
||||
type: "tiptap",
|
||||
id: noteId,
|
||||
ignoreEdit,
|
||||
sessionHistoryId: noteId ? editorSessionHistory.get(noteId) : undefined,
|
||||
tabId: tabId,
|
||||
pendingChanges,
|
||||
sourceNoteId,
|
||||
pendingChangesAt
|
||||
pendingChanges
|
||||
};
|
||||
|
||||
withTimer(
|
||||
`${noteId || tabId}:${type}`,
|
||||
noteId || "newnote",
|
||||
() => {
|
||||
if (!params.id && !params.sourceNoteId) {
|
||||
if (!params.id) {
|
||||
params.id = useTabStore.getState().getNoteIdForTab(tabId);
|
||||
}
|
||||
if (onChange && params.data) {
|
||||
@@ -1069,7 +1018,7 @@ export const useEditor = (
|
||||
saveNote(params);
|
||||
}
|
||||
},
|
||||
150
|
||||
ignoreEdit ? 0 : 150
|
||||
);
|
||||
},
|
||||
[editorSessionHistory, withTimer, onChange, saveNote]
|
||||
|
||||
@@ -30,7 +30,6 @@ import SettingsService from "../../services/settings";
|
||||
import useNavigationStore from "../../stores/use-navigation-store";
|
||||
import { useNotes } from "../../stores/use-notes-store";
|
||||
import { openEditor } from "../notes/common";
|
||||
import { db } from "../../common/database";
|
||||
|
||||
export const Home = ({ navigation, route }: NavigationProps<"Notes">) => {
|
||||
const [notes, loading] = useNotes();
|
||||
@@ -60,8 +59,7 @@ export const Home = ({ navigation, route }: NavigationProps<"Notes">) => {
|
||||
placeholder: strings.searchInRoute(route.name),
|
||||
type: "note",
|
||||
title: route.name,
|
||||
route: route.name,
|
||||
items: db.notes.all
|
||||
route: route.name
|
||||
});
|
||||
}}
|
||||
id={route.name}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function toCamelCase(title: string) {
|
||||
export function openMonographsWebpage() {
|
||||
try {
|
||||
openLinkInBrowser(
|
||||
"https://notesnook.com/help/publish-notes-with-monographs"
|
||||
"https://help.notesnook.com/publish-notes-with-monographs"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -208,15 +208,15 @@ export const settingsGroups: SettingSection[] = [
|
||||
return status === SubscriptionStatus.TRIAL
|
||||
? strings.trialOnGoing(trialEndDate)
|
||||
: status === SubscriptionStatus.ACTIVE
|
||||
? strings.subRenewOn(expiryDate)
|
||||
: status === SubscriptionStatus.CANCELED ||
|
||||
status === SubscriptionStatus.PAUSED
|
||||
? strings.subEndsOn(expiryDate)
|
||||
: status === SubscriptionStatus.EXPIRED
|
||||
? subscriptionDaysLeft.time < -3
|
||||
? strings.subEnded()
|
||||
: strings.accountDowngradedIn(3)
|
||||
: strings.neverHesitate();
|
||||
? strings.subRenewOn(expiryDate)
|
||||
: status === SubscriptionStatus.CANCELED ||
|
||||
status === SubscriptionStatus.PAUSED
|
||||
? strings.subEndsOn(expiryDate)
|
||||
: status === SubscriptionStatus.EXPIRED
|
||||
? subscriptionDaysLeft.time < -3
|
||||
? strings.subEnded()
|
||||
: strings.accountDowngradedIn(3)
|
||||
: strings.neverHesitate();
|
||||
}
|
||||
|
||||
return strings.neverHesitate();
|
||||
@@ -971,7 +971,7 @@ export const settingsGroups: SettingSection[] = [
|
||||
name: strings.keepScreenOn(),
|
||||
description: strings.keepScreenOnDesc(),
|
||||
property: "keepScreenOn",
|
||||
icon: "cellphone-screenshot"
|
||||
icon: "cellphone-screenshot",
|
||||
},
|
||||
{
|
||||
id: "image-compression",
|
||||
@@ -1679,7 +1679,7 @@ export const settingsGroups: SettingSection[] = [
|
||||
id: "docs-link",
|
||||
name: strings.documentation(),
|
||||
modifer: async () => {
|
||||
Linking.openURL("https://notesnook.com/help/");
|
||||
Linking.openURL("https://help.notesnook.com/");
|
||||
},
|
||||
description: strings.documentationDesc(),
|
||||
icon: "file-document"
|
||||
|
||||
@@ -472,7 +472,7 @@ function ThemeSelector() {
|
||||
actionText: strings.learnMore(),
|
||||
func: () => {
|
||||
openLinkInBrowser(
|
||||
"https://notesnook.com/help/custom-themes/introduction"
|
||||
"https://help.notesnook.com/custom-themes/introduction"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -177,11 +177,9 @@ function resetRootState(
|
||||
|
||||
if (state.routes.length < 2) return;
|
||||
|
||||
let routes = state.routes.filter(
|
||||
const routes = state.routes.filter(
|
||||
(route) =>
|
||||
(route.name !== "Auth" &&
|
||||
route.name !== "Welcome" &&
|
||||
route.name !== "AddReminder") ||
|
||||
(route.name !== "Auth" && route.name !== "Welcome") ||
|
||||
route.key === focusedRoute.key
|
||||
);
|
||||
|
||||
|
||||
@@ -34,10 +34,6 @@ export const NotePreviewWidget = {
|
||||
|
||||
NotesnookModule.updateWidgetNote(id, JSON.stringify(newNote));
|
||||
}
|
||||
// Redraw from the widgets that actually exist rather than only the ones we
|
||||
// have notes for. After app data is cleared there are none, and the widgets
|
||||
// would otherwise keep showing content that no longer exists.
|
||||
NotesnookModule.refreshWidgets();
|
||||
}, 500);
|
||||
},
|
||||
updateNote: async (id: string, note: Note) => {
|
||||
|
||||
@@ -17,12 +17,8 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getFormattedDate, getFormattedReminderTime } from "@notesnook/common";
|
||||
import {
|
||||
getUpcomingReminderTime,
|
||||
isReminderActive,
|
||||
Reminder
|
||||
} from "@notesnook/core";
|
||||
import { getFormattedReminderTime } from "@notesnook/common";
|
||||
import { isReminderActive, Reminder } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import notifee, {
|
||||
AndroidStyle,
|
||||
@@ -266,13 +262,8 @@ const onEvent = async ({ type, detail }: Event) => {
|
||||
|
||||
type ReminderWithFormattedTime = Reminder & {
|
||||
formattedTime?: string;
|
||||
triggerDate?: number;
|
||||
formattedTimeOfDay?: string;
|
||||
formattedDateTime?: string;
|
||||
};
|
||||
|
||||
const RECENTLY_PASSED_WINDOW = 3 * 60 * 60 * 1000;
|
||||
|
||||
async function updateRemindersForWidget() {
|
||||
if (Platform.OS === "ios") return;
|
||||
const reminders: ReminderWithFormattedTime[] = await db.reminders?.all.items(
|
||||
@@ -282,33 +273,18 @@ async function updateRemindersForWidget() {
|
||||
sortDirection: "asc"
|
||||
}
|
||||
);
|
||||
const widgetReminders = [];
|
||||
const activeReminders = [];
|
||||
if (!reminders) return;
|
||||
for (const reminder of reminders) {
|
||||
const triggerDate =
|
||||
reminder.snoozeUntil && reminder.snoozeUntil > Date.now()
|
||||
? reminder.snoozeUntil
|
||||
: reminder.mode === "repeat"
|
||||
? getUpcomingReminderTime(reminder)
|
||||
: reminder.date;
|
||||
|
||||
const recentlyPassed =
|
||||
reminder.mode === "once" &&
|
||||
!reminder.disabled &&
|
||||
triggerDate > Date.now() - RECENTLY_PASSED_WINDOW;
|
||||
|
||||
if (!isReminderActive(reminder) && !recentlyPassed) continue;
|
||||
|
||||
reminder.triggerDate = triggerDate;
|
||||
reminder.formattedTimeOfDay = getFormattedDate(triggerDate, "time");
|
||||
reminder.formattedDateTime = getFormattedDate(triggerDate, "date-time");
|
||||
reminder.formattedTime = getFormattedReminderTime(reminder);
|
||||
widgetReminders.push(reminder);
|
||||
if (isReminderActive(reminder)) {
|
||||
reminder.formattedTime = getFormattedReminderTime(reminder);
|
||||
activeReminders.push(reminder);
|
||||
}
|
||||
}
|
||||
NotesnookModule.setString(
|
||||
"appPreview",
|
||||
"remindersList",
|
||||
JSON.stringify(widgetReminders)
|
||||
JSON.stringify(activeReminders)
|
||||
);
|
||||
NotesnookModule.updateReminderWidget();
|
||||
}
|
||||
|
||||
@@ -74,21 +74,13 @@ export interface RouteParams extends ParamListBase {
|
||||
Tags: GenericRouteParam;
|
||||
Favorites: GenericRouteParam;
|
||||
Trash: GenericRouteParam;
|
||||
Search:
|
||||
| {
|
||||
placeholder: string;
|
||||
type: "note";
|
||||
title: string;
|
||||
route: RouteName;
|
||||
items: FilteredSelector<Note>;
|
||||
}
|
||||
| {
|
||||
placeholder: string;
|
||||
type: Exclude<ItemType, "note">;
|
||||
title: string;
|
||||
route: RouteName;
|
||||
items?: FilteredSelector<Item>;
|
||||
};
|
||||
Search: {
|
||||
placeholder: string;
|
||||
type: ItemType;
|
||||
title: string;
|
||||
route: RouteName;
|
||||
items?: FilteredSelector<Item>;
|
||||
};
|
||||
TaggedNotes: NotesScreenParams;
|
||||
ColoredNotes: NotesScreenParams;
|
||||
TopicNotes: NotesScreenParams;
|
||||
@@ -96,7 +88,7 @@ export interface RouteParams extends ParamListBase {
|
||||
Monographs: NotesScreenParams;
|
||||
Reminders: GenericRouteParam;
|
||||
SettingsGroup: GenericRouteParam;
|
||||
FluidPanelsView: { initialPage?: "editor" | "home" };
|
||||
FluidPanelsView: GenericRouteParam;
|
||||
AppLock: GenericRouteParam;
|
||||
Settings: GenericRouteParam;
|
||||
Auth: AuthParams;
|
||||
|
||||
@@ -27,7 +27,6 @@ import { ThemeDark, ThemeLight, ThemeDefinition } from "@notesnook/theme";
|
||||
import { DayFormat, WeekFormat, Reminder } from "@notesnook/core";
|
||||
import { db } from "../common/database";
|
||||
import { EDITOR_LINE_HEIGHT } from "../utils/constants";
|
||||
import { ShortcutItem } from "react-native-actions-shortcuts";
|
||||
export const HostIds = [
|
||||
"API_HOST",
|
||||
"AUTH_HOST",
|
||||
@@ -150,7 +149,6 @@ export interface SettingStore {
|
||||
refresh: () => void;
|
||||
inboxEnabled: boolean;
|
||||
setInboxEnabled: (inboxEnabled: boolean) => void;
|
||||
pendingShortcut: ShortcutItem | null;
|
||||
}
|
||||
|
||||
const { width, height } = Dimensions.get("window");
|
||||
@@ -271,6 +269,5 @@ export const useSettingStore = create<SettingStore>((set, get) => ({
|
||||
});
|
||||
},
|
||||
inboxEnabled: false,
|
||||
setInboxEnabled: (inboxEnabled) => set({ inboxEnabled }),
|
||||
pendingShortcut: null
|
||||
setInboxEnabled: (inboxEnabled) => set({ inboxEnabled })
|
||||
}));
|
||||
|
||||
@@ -45,7 +45,6 @@ interface NotesnookModuleInterface {
|
||||
hasWidgetNote: (noteId: string) => Promise<boolean>;
|
||||
updateWidgetNote: (noteId: string, data: string) => void;
|
||||
updateReminderWidget: () => void;
|
||||
refreshWidgets: () => void;
|
||||
isGestureNavigationEnabled: () => boolean;
|
||||
addShortcut: (
|
||||
id: string,
|
||||
@@ -82,7 +81,6 @@ export const NotesnookModule: NotesnookModuleInterface = Platform.select({
|
||||
hasWidgetNote: () => {},
|
||||
updateWidgetNote: () => {},
|
||||
updateReminderWidget: () => {},
|
||||
refreshWidgets: () => {},
|
||||
isGestureNavigationEnabled: () => true,
|
||||
addShortcut: () => Promise.resolve(false),
|
||||
removeShortcut: () => Promise.resolve(false),
|
||||
|
||||
@@ -4092,6 +4092,6 @@ SPEC CHECKSUMS:
|
||||
toolbar-android: c426ed5bd3dcccfed20fd79533efc0d1ae0ef018
|
||||
Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb
|
||||
|
||||
PODFILE CHECKSUM: 30b2045c0f4fc91402a43a9e2a872af803f2d6c3
|
||||
PODFILE CHECKSUM: 3fe13efa8356dcc061862bfa9f453dcd12ede70a
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Production iOS build identifiers
|
||||
IOS_CURRENT_PROJECT_VERSION = 2192
|
||||
IOS_MARKETING_VERSION = 3.4.8
|
||||
IOS_CURRENT_PROJECT_VERSION = 2191
|
||||
IOS_MARKETING_VERSION = 3.4.7
|
||||
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
|
||||
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
|
||||
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Production iOS build identifiers
|
||||
IOS_CURRENT_PROJECT_VERSION = 2192
|
||||
IOS_MARKETING_VERSION = 3.4.8
|
||||
IOS_CURRENT_PROJECT_VERSION = 2191
|
||||
IOS_MARKETING_VERSION = 3.4.7
|
||||
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
|
||||
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
|
||||
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Staging iOS build identifiers
|
||||
IOS_CURRENT_PROJECT_VERSION = 2192
|
||||
IOS_MARKETING_VERSION = 3.4.8
|
||||
IOS_CURRENT_PROJECT_VERSION = 2191
|
||||
IOS_MARKETING_VERSION = 3.4.7
|
||||
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
|
||||
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
|
||||
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@notesnook/mobile",
|
||||
"version": "3.4.8",
|
||||
"version": "3.4.7",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,98 +1,8 @@
|
||||
diff --git a/node_modules/react-native-iap/android/src/play/java/com/dooboolab/rniap/RNIapModule.kt b/node_modules/react-native-iap/android/src/play/java/com/dooboolab/rniap/RNIapModule.kt
|
||||
index 70149d2..cc2b272 100644
|
||||
index 70149d2..fc917f3 100644
|
||||
--- a/node_modules/react-native-iap/android/src/play/java/com/dooboolab/rniap/RNIapModule.kt
|
||||
+++ b/node_modules/react-native-iap/android/src/play/java/com/dooboolab/rniap/RNIapModule.kt
|
||||
@@ -15,10 +15,8 @@ import com.android.billingclient.api.GetBillingConfigParams
|
||||
import com.android.billingclient.api.GetBillingConfigParams.Builder
|
||||
import com.android.billingclient.api.ProductDetails
|
||||
import com.android.billingclient.api.Purchase
|
||||
-import com.android.billingclient.api.PurchaseHistoryRecord
|
||||
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||
import com.android.billingclient.api.QueryProductDetailsParams
|
||||
-import com.android.billingclient.api.QueryPurchaseHistoryParams
|
||||
import com.android.billingclient.api.QueryPurchasesParams
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.LifecycleEventListener
|
||||
@@ -38,11 +36,14 @@ import com.facebook.react.module.annotations.ReactModule
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
+import com.android.billingclient.api.PendingPurchasesParams
|
||||
|
||||
@ReactModule(name = RNIapModule.TAG)
|
||||
class RNIapModule(
|
||||
private val reactContext: ReactApplicationContext,
|
||||
- private val builder: BillingClient.Builder = BillingClient.newBuilder(reactContext).enablePendingPurchases(),
|
||||
+ private val builder: BillingClient.Builder = BillingClient.newBuilder(reactContext).enablePendingPurchases(
|
||||
+ PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(),
|
||||
+),
|
||||
private val googleApiAvailability: GoogleApiAvailability = GoogleApiAvailability.getInstance(),
|
||||
) : ReactContextBaseJavaModule(reactContext),
|
||||
PurchasesUpdatedListener {
|
||||
@@ -275,8 +276,10 @@ class RNIapModule(
|
||||
.setProductList(skuList)
|
||||
.build()
|
||||
|
||||
- billingClient.queryProductDetailsAsync(params) { billingResult, skuDetailsList ->
|
||||
- if (!isValidResult(billingResult, promise)) return@queryProductDetailsAsync
|
||||
+ billingClient.queryProductDetailsAsync(params) { billingResult, queryProductDetailsResult ->
|
||||
+ if (!isValidResult(billingResult, promise)) return@queryProductDetailsAsync
|
||||
+
|
||||
+ val skuDetailsList = queryProductDetailsResult.productDetailsList
|
||||
|
||||
val items = Arguments.createArray()
|
||||
for (skuDetails in skuDetailsList) {
|
||||
@@ -553,43 +556,12 @@ class RNIapModule(
|
||||
type: String,
|
||||
promise: Promise,
|
||||
) {
|
||||
- ensureConnection(
|
||||
- promise,
|
||||
- ) { billingClient ->
|
||||
- billingClient.queryPurchaseHistoryAsync(
|
||||
- QueryPurchaseHistoryParams
|
||||
- .newBuilder()
|
||||
- .setProductType(
|
||||
- if (type == "subs") BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP,
|
||||
- ).build(),
|
||||
- ) { billingResult: BillingResult, purchaseHistoryRecordList: MutableList<PurchaseHistoryRecord>? ->
|
||||
-
|
||||
- if (!isValidResult(billingResult, promise)) return@queryPurchaseHistoryAsync
|
||||
-
|
||||
- Log.d(TAG, purchaseHistoryRecordList.toString())
|
||||
- val items = Arguments.createArray()
|
||||
- purchaseHistoryRecordList?.forEach { purchase ->
|
||||
- val item = Arguments.createMap()
|
||||
- // Add both field names for compatibility
|
||||
- item.putString("productId", purchase.products[0])
|
||||
- item.putString("id", purchase.products[0])
|
||||
- val products = Arguments.createArray()
|
||||
- purchase.products.forEach { products.pushString(it) }
|
||||
- item.putArray("productIds", products)
|
||||
- item.putArray("ids", products)
|
||||
- item.putDouble("transactionDate", purchase.purchaseTime.toDouble())
|
||||
- item.putString("transactionReceipt", purchase.originalJson)
|
||||
- item.putString("purchaseToken", purchase.purchaseToken)
|
||||
- item.putString("purchaseTokenAndroid", purchase.purchaseToken)
|
||||
- item.putString("dataAndroid", purchase.originalJson)
|
||||
- item.putString("signatureAndroid", purchase.signature)
|
||||
- item.putString("developerPayload", purchase.developerPayload.orEmpty())
|
||||
- item.putString("platform", "android")
|
||||
- items.pushMap(item)
|
||||
- }
|
||||
- promise.safeResolve(items)
|
||||
- }
|
||||
- }
|
||||
+ promise.safeReject(
|
||||
+ "E_UNSUPPORTED",
|
||||
+ "getPurchaseHistoryByType is no longer supported since Play Billing Library 8 " +
|
||||
+ "removed queryPurchaseHistoryAsync. Use getAvailableItemsByType for active " +
|
||||
+ "purchases, or reconstruct history server-side.",
|
||||
+ )
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
@@ -604,7 +576,7 @@ class RNIapModule(
|
||||
@@ -604,7 +604,7 @@ class RNIapModule(
|
||||
isOfferPersonalized: Boolean, // New parameter in V5
|
||||
promise: Promise,
|
||||
) {
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function Monograph() {
|
||||
bg: "background-secondary",
|
||||
border: "1px solid var(--border)"
|
||||
}}
|
||||
href="https://notesnook.com/help/publish-notes-with-monographs"
|
||||
href="https://help.notesnook.com/publish-notes-with-monographs"
|
||||
target="_blank"
|
||||
>
|
||||
How it works
|
||||
|
||||
@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Button, Flex, Image, Text } from "@theme-ui/components";
|
||||
import { Box, Button, Flex, Image, Link, Text } from "@theme-ui/components";
|
||||
import { getRandom, usePromise } from "@notesnook/common";
|
||||
import Holenstein from "../../assets/testimonials/holenstein.jpg";
|
||||
import Jason from "../../assets/testimonials/jason.jpg";
|
||||
@@ -26,7 +26,6 @@ import Cameron from "../../assets/testimonials/cameron.jpg";
|
||||
import { hosts } from "@notesnook/core";
|
||||
import { SettingsDialog } from "../../dialogs/settings";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { FixedColorSchemeThemeProvider } from "../theme-provider";
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
@@ -81,18 +80,62 @@ function AuthContainer(props) {
|
||||
bg: "background"
|
||||
}}
|
||||
>
|
||||
<FixedColorSchemeThemeProvider
|
||||
colorScheme="dark"
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
flexDirection: "column",
|
||||
display: ["none", "none", "flex"],
|
||||
flex: 1,
|
||||
background:
|
||||
"radial-gradient(1200px 700px at 82% 18%, color-mix(in srgb, var(--accent) 14%, transparent) 0%, transparent 62%), var(--background-secondary)"
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
as="svg"
|
||||
version="1.1"
|
||||
viewBox="0 0 1920 1080"
|
||||
preserveAspectRatio="xMinYMin slice"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: -100,
|
||||
left: 0,
|
||||
height: "100%"
|
||||
// opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
<g mask='url("#SvgjsMask1017")' fill="none">
|
||||
<path
|
||||
d="M1184.21-85.14C1033.8-60.27 964.89 302.42 717.38 307.22 469.87 312.02 483.97 244.72 250.55 244.72 17.13 244.72-98.53 307.08-216.28 307.22"
|
||||
stroke="var(--icon)"
|
||||
strokeWidth="2"
|
||||
></path>
|
||||
<path
|
||||
d="M641.38-10.43C534.57 43 590.55 387.5 384.53 392.38 178.52 397.26 2.17 282.99-129.16 282.38"
|
||||
stroke="var(--icon)"
|
||||
strokeWidth="2"
|
||||
></path>
|
||||
<path
|
||||
d="M1136.18-29.24C957.53-5.77 852.26 404.49 561.01 405.07 269.76 405.65 142.54 160.4-14.16 155.07"
|
||||
stroke="var(--icon)"
|
||||
strokeWidth="2"
|
||||
></path>
|
||||
<path
|
||||
d="M508.47-71.88C398.16-66.29 333.42 117.75 114.38 127.84-104.65 137.93-170.96 308.31-279.7 312.84"
|
||||
stroke="var(--icon)"
|
||||
strokeWidth="2"
|
||||
></path>
|
||||
<path
|
||||
d="M1104.88-26.74C976.63-19.04 883.5 217.2 653.03 218.11 422.55 219.02 427.1 155.61 201.17 155.61-24.75 155.61-136.64 217.96-250.68 218.11"
|
||||
stroke="var(--icon)"
|
||||
strokeWidth="2"
|
||||
></path>
|
||||
</g>
|
||||
<defs>
|
||||
<mask id="SvgjsMask1017">
|
||||
<rect width="1440" height="500" fill="#ffffff"></rect>
|
||||
</mask>
|
||||
</defs>
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
p={50}
|
||||
sx={{
|
||||
@@ -119,9 +162,17 @@ function AuthContainer(props) {
|
||||
<Text
|
||||
variant="body"
|
||||
mt={10}
|
||||
sx={{ fontSize: 16, color: "paragraph-secondary" }}
|
||||
sx={{ fontSize: 14, color: "paragraph-secondary" }}
|
||||
>
|
||||
{testimonial.text}
|
||||
{testimonial.text} —{" "}
|
||||
<Link
|
||||
sx={{ fontStyle: "italic", color: "paragraph-secondary" }}
|
||||
href={testimonial.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
source
|
||||
</Link>
|
||||
</Text>
|
||||
<Flex mt={2} sx={{ alignItems: "center", justifyContent: "center" }}>
|
||||
<Image
|
||||
@@ -129,12 +180,10 @@ function AuthContainer(props) {
|
||||
sx={{ borderRadius: 50, width: 40 }}
|
||||
/>
|
||||
<Flex ml={2} sx={{ flexDirection: "column" }}>
|
||||
<Text variant="body" sx={{ fontSize: 16, fontWeight: "bold" }}>
|
||||
<Text variant="body" sx={{ fontSize: 14, fontWeight: "bold" }}>
|
||||
{testimonial.name}
|
||||
</Text>
|
||||
<Text variant="subBody" sx={{ fontSize: 13 }}>
|
||||
@{testimonial.username}
|
||||
</Text>
|
||||
<Text variant="subBody">@{testimonial.username}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -169,18 +218,52 @@ function AuthContainer(props) {
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</FixedColorSchemeThemeProvider>
|
||||
<FixedColorSchemeThemeProvider
|
||||
colorScheme="light"
|
||||
</Box>
|
||||
<Flex
|
||||
sx={{
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
flex: 1.5,
|
||||
background: "var(--background-secondary)"
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
as="svg"
|
||||
version="1.1"
|
||||
viewBox="0 0 1920 1080"
|
||||
preserveAspectRatio="xMinYMin slice"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: "130%",
|
||||
height: "100%"
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M0 336L29.2 316.2C58.3 296.3 116.7 256.7 174.8 267.5C233 278.3 291 339.7 349.2 361.3C407.3 383 465.7 365 523.8 359.5C582 354 640 361 698.2 346.5C756.3 332 814.7 296 872.8 267.2C931 238.3 989 216.7 1047.2 202.3C1105.3 188 1163.7 181 1221.8 202.7C1280 224.3 1338 274.7 1396.2 298C1454.3 321.3 1512.7 317.7 1570.8 332C1629 346.3 1687 378.7 1745.2 366.2C1803.3 353.7 1861.7 296.3 1890.8 267.7L1920 239L1920 0L1890.8 0C1861.7 0 1803.3 0 1745.2 0C1687 0 1629 0 1570.8 0C1512.7 0 1454.3 0 1396.2 0C1338 0 1280 0 1221.8 0C1163.7 0 1105.3 0 1047.2 0C989 0 931 0 872.8 0C814.7 0 756.3 0 698.2 0C640 0 582 0 523.8 0C465.7 0 407.3 0 349.2 0C291 0 233 0 174.8 0C116.7 0 58.3 0 29.2 0L0 0Z"
|
||||
fill="var(--background-secondary)"
|
||||
></path>
|
||||
<path
|
||||
d="M0 627L29.2 607.3C58.3 587.7 116.7 548.3 174.8 564.7C233 581 291 653 349.2 683.5C407.3 714 465.7 703 523.8 703C582 703 640 714 698.2 724.8C756.3 735.7 814.7 746.3 872.8 742.7C931 739 989 721 1047.2 670.7C1105.3 620.3 1163.7 537.7 1221.8 528.7C1280 519.7 1338 584.3 1396.2 623.8C1454.3 663.3 1512.7 677.7 1570.8 666.8C1629 656 1687 620 1745.2 602C1803.3 584 1861.7 584 1890.8 584L1920 584L1920 237L1890.8 265.7C1861.7 294.3 1803.3 351.7 1745.2 364.2C1687 376.7 1629 344.3 1570.8 330C1512.7 315.7 1454.3 319.3 1396.2 296C1338 272.7 1280 222.3 1221.8 200.7C1163.7 179 1105.3 186 1047.2 200.3C989 214.7 931 236.3 872.8 265.2C814.7 294 756.3 330 698.2 344.5C640 359 582 352 523.8 357.5C465.7 363 407.3 381 349.2 359.3C291 337.7 233 276.3 174.8 265.5C116.7 254.7 58.3 294.3 29.2 314.2L0 334Z"
|
||||
fill="var(--hover)"
|
||||
></path>
|
||||
<path
|
||||
d="M0 735L29.2 731.5C58.3 728 116.7 721 174.8 739C233 757 291 800 349.2 832.3C407.3 864.7 465.7 886.3 523.8 886.3C582 886.3 640 864.7 698.2 859.3C756.3 854 814.7 865 872.8 870.5C931 876 989 876 1047.2 845.3C1105.3 814.7 1163.7 753.3 1221.8 729.8C1280 706.3 1338 720.7 1396.2 738.7C1454.3 756.7 1512.7 778.3 1570.8 789.2C1629 800 1687 800 1745.2 814.5C1803.3 829 1861.7 858 1890.8 872.5L1920 887L1920 582L1890.8 582C1861.7 582 1803.3 582 1745.2 600C1687 618 1629 654 1570.8 664.8C1512.7 675.7 1454.3 661.3 1396.2 621.8C1338 582.3 1280 517.7 1221.8 526.7C1163.7 535.7 1105.3 618.3 1047.2 668.7C989 719 931 737 872.8 740.7C814.7 744.3 756.3 733.7 698.2 722.8C640 712 582 701 523.8 701C465.7 701 407.3 712 349.2 681.5C291 651 233 579 174.8 562.7C116.7 546.3 58.3 585.7 29.2 605.3L0 625Z"
|
||||
fill="var(--border)"
|
||||
></path>
|
||||
<path
|
||||
d="M0 897L29.2 895.3C58.3 893.7 116.7 890.3 174.8 908.3C233 926.3 291 965.7 349.2 985.3C407.3 1005 465.7 1005 523.8 1003.3C582 1001.7 640 998.3 698.2 996.7C756.3 995 814.7 995 872.8 986C931 977 989 959 1047.2 939.2C1105.3 919.3 1163.7 897.7 1221.8 894C1280 890.3 1338 904.7 1396.2 911.8C1454.3 919 1512.7 919 1570.8 928C1629 937 1687 955 1745.2 960.3C1803.3 965.7 1861.7 958.3 1890.8 954.7L1920 951L1920 885L1890.8 870.5C1861.7 856 1803.3 827 1745.2 812.5C1687 798 1629 798 1570.8 787.2C1512.7 776.3 1454.3 754.7 1396.2 736.7C1338 718.7 1280 704.3 1221.8 727.8C1163.7 751.3 1105.3 812.7 1047.2 843.3C989 874 931 874 872.8 868.5C814.7 863 756.3 852 698.2 857.3C640 862.7 582 884.3 523.8 884.3C465.7 884.3 407.3 862.7 349.2 830.3C291 798 233 755 174.8 737C116.7 719 58.3 726 29.2 729.5L0 733Z"
|
||||
fill="var(--hover)"
|
||||
></path>
|
||||
<path
|
||||
d="M0 1081L29.2 1081C58.3 1081 116.7 1081 174.8 1081C233 1081 291 1081 349.2 1081C407.3 1081 465.7 1081 523.8 1081C582 1081 640 1081 698.2 1081C756.3 1081 814.7 1081 872.8 1081C931 1081 989 1081 1047.2 1081C1105.3 1081 1163.7 1081 1221.8 1081C1280 1081 1338 1081 1396.2 1081C1454.3 1081 1512.7 1081 1570.8 1081C1629 1081 1687 1081 1745.2 1081C1803.3 1081 1861.7 1081 1890.8 1081L1920 1081L1920 949L1890.8 952.7C1861.7 956.3 1803.3 963.7 1745.2 958.3C1687 953 1629 935 1570.8 926C1512.7 917 1454.3 917 1396.2 909.8C1338 902.7 1280 888.3 1221.8 892C1163.7 895.7 1105.3 917.3 1047.2 937.2C989 957 931 975 872.8 984C814.7 993 756.3 993 698.2 994.7C640 996.3 582 999.7 523.8 1001.3C465.7 1003 407.3 1003 349.2 983.3C291 963.7 233 924.3 174.8 906.3C116.7 888.3 58.3 891.7 29.2 893.3L0 895Z"
|
||||
fill="var(--border)"
|
||||
></path>
|
||||
</Box>
|
||||
{props.children}
|
||||
</FixedColorSchemeThemeProvider>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -918,7 +918,7 @@ function NavigationDropdown() {
|
||||
icon: Documentation.path,
|
||||
key: "help-and-support",
|
||||
onClick: () => {
|
||||
window.open("https://notesnook.com/help/", "_blank");
|
||||
window.open("https://help.notesnook.com/", "_blank");
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -550,7 +550,7 @@ export const noteMenuItems: (
|
||||
|
||||
await exportNotes(
|
||||
format.type,
|
||||
db.notes.exportable.where((eb) => eb("id", "in", ids))
|
||||
db.notes.all.where((eb) => eb("id", "in", ids))
|
||||
);
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -19,7 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import {
|
||||
EmotionThemeProvider,
|
||||
FixedThemeProvider,
|
||||
ThemeScopes,
|
||||
themeToCSS,
|
||||
useThemeEngineStore
|
||||
@@ -95,25 +94,4 @@ export function BaseThemeProvider(
|
||||
);
|
||||
}
|
||||
|
||||
export function FixedColorSchemeThemeProvider(
|
||||
props: PropsWithChildren<
|
||||
{
|
||||
injectCssVars?: boolean;
|
||||
scope?: keyof ThemeScopes;
|
||||
colorScheme: "light" | "dark";
|
||||
} & Omit<BoxProps, "variant">
|
||||
>
|
||||
) {
|
||||
const { children, scope = "base", ...restProps } = props;
|
||||
const theme = useThemeStore((store) =>
|
||||
props.colorScheme === "dark" ? store.darkTheme : store.lightTheme
|
||||
);
|
||||
|
||||
return (
|
||||
<FixedThemeProvider {...restProps} scope={scope} theme={theme}>
|
||||
{children}
|
||||
</FixedThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { EmotionThemeProvider as ScopedThemeProvider };
|
||||
|
||||
@@ -33,21 +33,15 @@ type UnlockViewProps = {
|
||||
};
|
||||
export function UnlockView(props: UnlockViewProps) {
|
||||
const { title, subtitle, buttonTitle, unlock } = props;
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [isWrong, setIsWrong] = useState(false);
|
||||
const [isUnlocking, setIsUnlocking] = useState(false);
|
||||
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const password = passwordRef?.current?.value;
|
||||
if (!password) {
|
||||
setErrorMessage(strings.passwordRequired());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordRef.current?.value) return;
|
||||
setIsUnlocking(true);
|
||||
const password = passwordRef.current.value;
|
||||
try {
|
||||
await unlock(password);
|
||||
} catch (e) {
|
||||
@@ -55,7 +49,7 @@ export function UnlockView(props: UnlockViewProps) {
|
||||
e instanceof Error &&
|
||||
e.message.includes("ciphertext cannot be decrypted using that key")
|
||||
) {
|
||||
setErrorMessage(strings.passwordIncorrect());
|
||||
setIsWrong(true);
|
||||
} else {
|
||||
showToast("error", `${strings.couldNotUnlock()}: ` + e);
|
||||
console.error(e);
|
||||
@@ -63,7 +57,7 @@ export function UnlockView(props: UnlockViewProps) {
|
||||
} finally {
|
||||
setIsUnlocking(false);
|
||||
}
|
||||
}, [setErrorMessage, unlock]);
|
||||
}, [setIsWrong, unlock]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -116,12 +110,12 @@ export function UnlockView(props: UnlockViewProps) {
|
||||
onKeyUp={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
await submit();
|
||||
} else if (errorMessage) {
|
||||
setErrorMessage(undefined);
|
||||
} else if (isWrong) {
|
||||
setIsWrong(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errorMessage && <ErrorText sx={{ mt: 1 }} error={errorMessage} />}
|
||||
{isWrong && <ErrorText sx={{ mt: 1 }} error="Wrong password" />}
|
||||
<Button
|
||||
mt={3}
|
||||
variant="accent"
|
||||
|
||||
@@ -179,7 +179,7 @@ const staticCommands: Command[] = [
|
||||
id: "help",
|
||||
title: strings.helpAndSupport(),
|
||||
icon: ArrowTopRight,
|
||||
action: () => (window.location.href = "https://notesnook.com/help"),
|
||||
action: () => (window.location.href = "https://help.notesnook.com"),
|
||||
group: strings.navigate(),
|
||||
type: "command"
|
||||
},
|
||||
|
||||
@@ -386,7 +386,7 @@ export const SupportSettings: SettingsGroup[] = [
|
||||
{
|
||||
type: "button",
|
||||
action: () =>
|
||||
void window.open("https://notesnook.com/help/", "_blank"),
|
||||
void window.open("https://help.notesnook.com/", "_blank"),
|
||||
title: strings.open(),
|
||||
variant: "secondary"
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export const PrivacySettings: SettingsGroup[] = [
|
||||
if (!result) return;
|
||||
try {
|
||||
const url = new URL(result);
|
||||
Config.set("corsProxy", url.href);
|
||||
Config.set("corsProxy", `${url.protocol}//${url.hostname}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast("error", strings.invalidCors());
|
||||
|
||||
@@ -181,7 +181,7 @@ const DEFAULT_TIPS: Record<TipContext, Omit<Tip, "contexts">> = {
|
||||
icon: ArrowTopRight,
|
||||
onClick() {
|
||||
window.open(
|
||||
"https://notesnook.com/help/publish-notes-with-monographs",
|
||||
"https://help.notesnook.com/publish-notes-with-monographs",
|
||||
"_blank"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ function Signup(props: BaseAuthComponentProps<"signup">) {
|
||||
<Text
|
||||
mt={4}
|
||||
variant="subBody"
|
||||
sx={{ fontSize: "subBody", textAlign: "center" }}
|
||||
sx={{ fontSize: 13, textAlign: "center" }}
|
||||
>
|
||||
{strings.signupAgreement[0]()}{" "}
|
||||
<Link
|
||||
@@ -843,6 +843,9 @@ export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [form, setForm] = useState<AuthFormData[T] | undefined>();
|
||||
|
||||
if (isSubmitting)
|
||||
return <Loader title={props.loading.title} text={props.loading.subtitle} />;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={formRef}
|
||||
@@ -873,74 +876,57 @@ export function AuthForm<T extends AuthRoutes>(props: AuthFormProps<T>) {
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
size: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
justifyContent: "center",
|
||||
width: ["95%", "95%", "45%"],
|
||||
alignSelf: "center"
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
<Text variant={"heading"} sx={{ fontSize: 32, textAlign: "center" }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
mt={2}
|
||||
mb={35}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
width: ["95%", "95%", "550px"],
|
||||
background: "var(--background)",
|
||||
p: 6,
|
||||
my: 10,
|
||||
borderRadius: "15px",
|
||||
border: "1px solid var(--border)",
|
||||
boxShadow: "0px 0px 10px 0px #00000019"
|
||||
fontSize: "title",
|
||||
textAlign: "center",
|
||||
color: "var(--paragraph-secondary)"
|
||||
}}
|
||||
>
|
||||
<Text variant={"heading"} sx={{ fontSize: 32 }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
mt={2}
|
||||
mb={2}
|
||||
{subtitle}
|
||||
</Text>
|
||||
{typeof children === "function" ? children(form) : children}
|
||||
{canSkip && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="anchor"
|
||||
sx={{
|
||||
fontSize: "title",
|
||||
color: "var(--paragraph-secondary)"
|
||||
mt: 5,
|
||||
color: "paragraph",
|
||||
textDecoration: "none",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 5
|
||||
}}
|
||||
onClick={async () => {
|
||||
const result = await ConfirmDialog.show({
|
||||
title: strings.offlineMode(),
|
||||
message: strings.offlineModeDesc(),
|
||||
negativeButtonText: strings.cancel(),
|
||||
positiveButtonText: strings.understand()
|
||||
});
|
||||
if (result) openURL("/notes/", { authenticated: false });
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{canSkip && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
bg: "transparent",
|
||||
border: "2px solid var(--border)",
|
||||
borderRadius: "default",
|
||||
px: 2
|
||||
}}
|
||||
onClick={async () => {
|
||||
const result = await ConfirmDialog.show({
|
||||
title: strings.offlineMode(),
|
||||
message: strings.offlineModeDesc(),
|
||||
negativeButtonText: strings.cancel(),
|
||||
positiveButtonText: strings.understand()
|
||||
});
|
||||
if (result) openURL("/notes/", { authenticated: false });
|
||||
}}
|
||||
>
|
||||
{strings.skipAndGoToApp()}
|
||||
</Button>
|
||||
)}
|
||||
{isSubmitting ? (
|
||||
<Loader title={props.loading.title} text={props.loading.subtitle} />
|
||||
) : typeof children === "function" ? (
|
||||
children(form)
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{strings.skipAndGoToApp()}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ErrorText error={error} mt={5} />
|
||||
</Flex>
|
||||
<ErrorText error={error} mt={5} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -962,7 +948,8 @@ function SubtitleWithAction(props: SubtitleWithActionProps) {
|
||||
sx={{
|
||||
textDecoration: "underline",
|
||||
fontWeight: "bold",
|
||||
fontSize: "title",
|
||||
fontSize: "subtitle",
|
||||
color: "paragraph",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
onClick={props.action.onClick}
|
||||
@@ -982,11 +969,7 @@ export function AuthField(props: FieldProps) {
|
||||
data-test-id={props["data-test-id"] || props.id}
|
||||
sx={{ mt: 2, width: "100%" }}
|
||||
styles={{
|
||||
label: { fontWeight: "normal", fontSize: "subtitle" },
|
||||
helpText: {
|
||||
fontSize: "body",
|
||||
my: "2px"
|
||||
},
|
||||
// label: { fontWeight: "normal" },
|
||||
input: {
|
||||
p: "12px",
|
||||
borderRadius: "default",
|
||||
@@ -1009,20 +992,21 @@ type SubmitButtonProps = {
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
sx?: Record<string, unknown>;
|
||||
};
|
||||
export function SubmitButton(props: SubmitButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
data-test-id="submitButton"
|
||||
type="submit"
|
||||
mt={50}
|
||||
variant="accent"
|
||||
px={50}
|
||||
sx={{
|
||||
alignSelf: "stretch",
|
||||
py: 2,
|
||||
mt: 3,
|
||||
fontSize: "subtitle",
|
||||
...props.sx
|
||||
borderRadius: 50,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignSelf: "center",
|
||||
display: "flex"
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ErrorText } from "../components/error-text";
|
||||
import { EVENTS, User } from "@notesnook/core";
|
||||
import { RecoveryKeyDialog } from "../dialogs/recovery-key-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useKeyStore } from "../interfaces/key-store";
|
||||
|
||||
type RecoveryMethodType = "key" | "reset";
|
||||
type RecoveryMethodsFormData = Record<string, unknown>;
|
||||
@@ -104,8 +105,8 @@ function useAuthenticateUser({
|
||||
code,
|
||||
userId
|
||||
}: {
|
||||
code?: string;
|
||||
userId?: string;
|
||||
code: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(true);
|
||||
const [user, setUser] = useState<User>();
|
||||
@@ -113,10 +114,6 @@ function useAuthenticateUser({
|
||||
async function authenticateUser() {
|
||||
setIsAuthenticating(true);
|
||||
try {
|
||||
if (!code || !userId) {
|
||||
throw new Error("Missing code or userId in query params.");
|
||||
}
|
||||
|
||||
const accessToken = await db.tokenManager.getAccessToken();
|
||||
if (!accessToken) {
|
||||
await db.tokenManager.getAccessTokenFromAuthorizationCode(
|
||||
@@ -184,7 +181,7 @@ function Recovery(props: RecoveryProps) {
|
||||
}}
|
||||
variant={"body"}
|
||||
>
|
||||
{user?.email ? strings.authenticatedAs(user.email) : ""}
|
||||
{strings.authenticatedAs(user?.email)}
|
||||
</Text>
|
||||
<Button
|
||||
sx={{
|
||||
@@ -337,25 +334,16 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
|
||||
subtitle: strings.keyRecoveryProgressDesc()
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
const user = await db.user.getUser();
|
||||
if (!user) throw new Error(strings.notLoggedIn());
|
||||
|
||||
const recoveryKey = form.recoveryKey;
|
||||
if (
|
||||
!(await db
|
||||
.storage()
|
||||
.encrypt({ key: recoveryKey, salt: user.salt }, "test")
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
) {
|
||||
if (recoveryKey.length < 40) {
|
||||
throw new Error(strings.invalidRecoveryKey());
|
||||
}
|
||||
|
||||
await db.user.verifyEncryptionKey({
|
||||
key: recoveryKey,
|
||||
salt: user.salt
|
||||
});
|
||||
setProgress(0);
|
||||
|
||||
const user = await db.user.getUser();
|
||||
if (!user) throw new Error(strings.notLoggedIn());
|
||||
await useKeyStore.getState().setValue("userEncryptionKey", recoveryKey);
|
||||
navigate("new", form);
|
||||
}}
|
||||
>
|
||||
@@ -368,19 +356,16 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
|
||||
autoFocus
|
||||
defaultValue={formData?.recoveryKey || ""}
|
||||
/>
|
||||
<Flex sx={{ gap: 1, mt: 3 }}>
|
||||
<Flex sx={{ gap: 1 }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
sx={{ flex: 1, py: 2, fontSize: "subtitle" }}
|
||||
sx={{ mt: 50, borderRadius: 50 }}
|
||||
onClick={() => navigate("methods")}
|
||||
>
|
||||
{strings.back()}
|
||||
</Button>
|
||||
<SubmitButton
|
||||
text={strings.startAccountRecovery()}
|
||||
sx={{ flex: 1, mt: 0 }}
|
||||
/>
|
||||
<SubmitButton text={strings.startAccountRecovery()} />
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
@@ -420,28 +405,29 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
|
||||
subtitle: strings.resetPasswordWait()
|
||||
}}
|
||||
onSubmit={async (form) => {
|
||||
setProgress(0);
|
||||
const user = await db.user.getUser();
|
||||
if (!user) throw new Error(strings.notLoggedIn());
|
||||
try {
|
||||
setProgress(0);
|
||||
|
||||
if (!formData?.recoveryKey)
|
||||
throw new Error("Recovery key is required to reset password.");
|
||||
if (form.password !== form.confirmPassword)
|
||||
throw new Error("Passwords do not match.");
|
||||
|
||||
if (form.password !== form.confirmPassword)
|
||||
throw new Error("Passwords do not match.");
|
||||
if (formData?.userResetRequired && !(await db.user.resetUser()))
|
||||
throw new Error("Failed to reset user.");
|
||||
|
||||
if (formData?.userResetRequired && !(await db.user.resetUser()))
|
||||
throw new Error("Failed to reset user.");
|
||||
if (!(await db.user.resetPassword(form.password)))
|
||||
throw new Error("Could not reset account password.");
|
||||
|
||||
if (
|
||||
!(await db.user.resetPassword({
|
||||
encryptionKey: { key: formData?.recoveryKey, salt: user.salt },
|
||||
newPassword: form.password
|
||||
}))
|
||||
)
|
||||
throw new Error("Could not reset account password.");
|
||||
navigate("final");
|
||||
} catch (e) {
|
||||
if ((e as Error).message === "invalid input") {
|
||||
console.error(e);
|
||||
throw new Error(
|
||||
"Password reset failed because of invalid recovery key"
|
||||
);
|
||||
}
|
||||
|
||||
navigate("final");
|
||||
throw e;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(form?: NewPasswordFormData) => (
|
||||
@@ -461,11 +447,11 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
|
||||
label={strings.confirmPassword()}
|
||||
defaultValue={form?.confirmPassword}
|
||||
/>
|
||||
<Flex sx={{ gap: 1, mt: 3 }}>
|
||||
<Flex sx={{ gap: 1 }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
sx={{ flex: 1, py: 2, fontSize: "subtitle" }}
|
||||
sx={{ mt: 50, borderRadius: 50 }}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
formData?.userResetRequired ? "methods" : "method:key",
|
||||
@@ -475,7 +461,7 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
|
||||
>
|
||||
{strings.back()}
|
||||
</Button>
|
||||
<SubmitButton text={strings.continue()} sx={{ flex: 1, mt: 0 }} />
|
||||
<SubmitButton text={strings.continue()} />
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
@@ -553,41 +539,31 @@ export function RecoveryForm<T extends RecoveryRoutes>(
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
size: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
justifyContent: "center",
|
||||
width: ["95%", 420],
|
||||
alignSelf: "center"
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
<Text variant={"heading"} sx={{ fontSize: 32, textAlign: "center" }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
mt={2}
|
||||
mb={35}
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
width: ["95%", "95%", "550px"],
|
||||
background: "var(--background)",
|
||||
p: 6,
|
||||
my: 10,
|
||||
borderRadius: "15px",
|
||||
border: "1px solid var(--border)",
|
||||
boxShadow: "0px 0px 10px 0px #00000019"
|
||||
fontSize: "title",
|
||||
textAlign: "center",
|
||||
color: "var(--paragraph-secondary)"
|
||||
}}
|
||||
>
|
||||
<Text variant={"heading"} sx={{ fontSize: 32 }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
mt={2}
|
||||
mb={2}
|
||||
sx={{
|
||||
fontSize: "title",
|
||||
color: "var(--paragraph-secondary)"
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{typeof children === "function" ? children(form) : children}
|
||||
<ErrorText error={error} sx={{ mt: 2 }} />
|
||||
</Flex>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{typeof children === "function" ? children(form) : children}
|
||||
<ErrorText error={error} sx={{ mt: 2 }} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
5
docs/help/.gitignore
vendored
5
docs/help/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
.vitepress/dist
|
||||
.vitepress/cache
|
||||
contents/v*/
|
||||
.vitepress/sidebars/generated.mjs
|
||||
.wrangler
|
||||
@@ -1,222 +0,0 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
import { tabsMarkdownPlugin } from "vitepress-plugin-tabs";
|
||||
import taskLists from "markdown-it-task-lists";
|
||||
import { sidebar } from "./sidebar.mjs";
|
||||
import {
|
||||
LATEST,
|
||||
isArchivedPath,
|
||||
versionOfPath,
|
||||
versionsNavItem
|
||||
} from "./versions.mjs";
|
||||
// Latest docs live at the root; the /v<version>/ trees and their sidebars are
|
||||
// composed from contents/_versions/ by scripts/build-versions.mjs, which runs
|
||||
// before dev and build.
|
||||
import { archivedSidebars } from "./sidebars/generated.mjs";
|
||||
import { seoHead, seoTitle } from "./seo.mjs";
|
||||
import { stringsMarkdownPlugin } from "./strings.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
title: "Notesnook Help",
|
||||
description:
|
||||
"Your complete and free resource to using Notesnook as a daily note taking app to organize your work and life while safeguarding your privacy.",
|
||||
lang: "en-US",
|
||||
srcDir: "./contents",
|
||||
base: "/help/",
|
||||
outDir: "./.vitepress/dist/help",
|
||||
// Version overrides are source material for build-versions.mjs, not pages.
|
||||
// The Standard Notes importer is unpublished for now — the page is kept in
|
||||
// the repo but is not built, linked or listed in the sitemap. Delete the
|
||||
// second entry (and restore the sidebar link) to publish it again.
|
||||
srcExclude: [
|
||||
"_versions/**",
|
||||
"importing-notes/import-notes-from-standardnotes.md",
|
||||
"self-hosting.md"
|
||||
],
|
||||
cleanUrls: true,
|
||||
lastUpdated: true,
|
||||
metaChunk: true,
|
||||
sitemap: {
|
||||
hostname: "https://notesnook.com/help",
|
||||
// Only the latest docs belong in the sitemap.
|
||||
transformItems: (items) =>
|
||||
items.filter(
|
||||
(i) => !isArchivedPath(`/${i.url}`) && !i.url.startsWith("404")
|
||||
)
|
||||
},
|
||||
|
||||
transformPageData(pageData, ctx) {
|
||||
const path = `/${pageData.relativePath}`;
|
||||
pageData.frontmatter.head ??= [];
|
||||
|
||||
// Archived pages are kept out of search engines so they don't compete with
|
||||
// the latest docs, and are tagged so the layout can show a version banner.
|
||||
if (isArchivedPath(path)) {
|
||||
pageData.frontmatter.archivedVersion = versionOfPath(path);
|
||||
pageData.frontmatter.latestVersion = LATEST;
|
||||
pageData.frontmatter.head.push([
|
||||
"meta",
|
||||
{ name: "robots", content: "noindex,follow" }
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Canonical, Open Graph, Twitter cards and JSON-LD for the live docs.
|
||||
seoTitle(pageData);
|
||||
pageData.frontmatter.head.push(...seoHead(pageData, ctx));
|
||||
},
|
||||
|
||||
head: [
|
||||
["link", { rel: "icon", href: "/favicon.ico" }],
|
||||
// The two weights that render above the fold on every page.
|
||||
[
|
||||
"link",
|
||||
{
|
||||
rel: "preload",
|
||||
href: "/help/fonts/Inter-Regular.woff2",
|
||||
as: "font",
|
||||
type: "font/woff2",
|
||||
crossorigin: ""
|
||||
}
|
||||
],
|
||||
[
|
||||
"link",
|
||||
{
|
||||
rel: "preload",
|
||||
href: "/help/fonts/Inter-SemiBold.woff2",
|
||||
as: "font",
|
||||
type: "font/woff2",
|
||||
crossorigin: ""
|
||||
}
|
||||
],
|
||||
["meta", { name: "theme-color", content: "#008837" }],
|
||||
["meta", { property: "og:type", content: "website" }],
|
||||
["meta", { property: "og:site_name", content: "Notesnook Help" }],
|
||||
["meta", { property: "og:image", content: "/logo.png" }],
|
||||
[
|
||||
"script",
|
||||
{
|
||||
async: "",
|
||||
defer: "",
|
||||
"data-website-id": "676a7449-2151-44f7-a8c7-3b0691cade30",
|
||||
src: "https://aas.streetwriters.co/script.js",
|
||||
"data-domains": "notesnook.com"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
markdown: {
|
||||
config(md) {
|
||||
md.use(tabsMarkdownPlugin);
|
||||
// `- [x] item` renders as a real checkbox instead of literal "[x]".
|
||||
md.use(taskLists, { label: true, labelAfter: true });
|
||||
|
||||
// `{{archive}}` becomes the live label from packages/intl.
|
||||
md.use(stringsMarkdownPlugin);
|
||||
|
||||
// An image that shares a line with text is a UI glyph ("press the ⋯
|
||||
// button"), not a figure. Tag those so CSS can keep them in the line —
|
||||
// :only-child can't be used for this because it ignores text nodes.
|
||||
md.core.ruler.push("nn_inline_glyphs", (state) => {
|
||||
for (const token of state.tokens) {
|
||||
if (token.type !== "inline" || !token.children) continue;
|
||||
// Line breaks split the inline token into segments. A screenshot on
|
||||
// its own line inside a numbered step lives in the same inline token
|
||||
// as the step's text, so "does this token contain text?" would wrongly
|
||||
// shrink it — the question is whether text sits on *its* line.
|
||||
let segment: typeof token.children = [];
|
||||
const segments = [segment];
|
||||
for (const child of token.children) {
|
||||
if (child.type === "softbreak" || child.type === "hardbreak") {
|
||||
segment = [];
|
||||
segments.push(segment);
|
||||
} else segment.push(child);
|
||||
}
|
||||
for (const line of segments) {
|
||||
const sharesLineWithText = line.some(
|
||||
(child) =>
|
||||
(child.type === "text" && child.content.trim()) ||
|
||||
child.type === "code_inline"
|
||||
);
|
||||
if (!sharesLineWithText) continue;
|
||||
for (const child of line) {
|
||||
if (child.type === "image")
|
||||
child.attrJoin("class", "inline-glyph");
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
image: { lazyLoading: true }
|
||||
},
|
||||
|
||||
themeConfig: {
|
||||
logo: "/logo.png",
|
||||
siteTitle: "Help",
|
||||
|
||||
nav: [
|
||||
versionsNavItem,
|
||||
{ text: "Downloads", link: "https://notesnook.com/downloads" },
|
||||
{ text: "Pricing", link: "https://notesnook.com/pricing" },
|
||||
{
|
||||
text: "More",
|
||||
items: [
|
||||
{ text: "Notesnook", link: "https://notesnook.com" },
|
||||
{ text: "Blog", link: "https://blog.notesnook.com" },
|
||||
{ text: "Roadmap", link: "https://notesnook.com/roadmap" },
|
||||
{ text: "Contact us", link: "https://notesnook.com/contact-us" },
|
||||
{
|
||||
text: "Report an issue",
|
||||
link: "https://github.com/streetwriters/notesnook/issues/new/choose"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
sidebar: { ...archivedSidebars, "/": sidebar },
|
||||
|
||||
search: {
|
||||
provider: "local",
|
||||
options: {
|
||||
detailedView: true,
|
||||
// Archived versions are excluded so a search for "archive a note" does
|
||||
// not return the same article once per version.
|
||||
_render(src, env, md) {
|
||||
if (isArchivedPath(`/${env.relativePath}`)) return "";
|
||||
return md.render(src, env);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
outline: { level: [2, 3], label: "On this page" },
|
||||
|
||||
editLink: {
|
||||
pattern:
|
||||
"https://github.com/streetwriters/notesnook/edit/master/docs/help/contents/:path",
|
||||
text: "Suggest an edit to this page"
|
||||
},
|
||||
|
||||
lastUpdated: {
|
||||
text: "Last updated",
|
||||
formatOptions: { dateStyle: "medium", forceLocale: false }
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/streetwriters/notesnook" },
|
||||
{ icon: "mastodon", link: "https://mastodon.social/@notesnook" },
|
||||
{ icon: "discord", link: "https://discord.com/invite/zQBK97EE22" },
|
||||
{ icon: "x", link: "https://x.com/notesnook" }
|
||||
],
|
||||
|
||||
footer: {
|
||||
message:
|
||||
'Made with care by <a href="https://streetwriters.co">Streetwriters</a>. Notesnook is <a href="https://github.com/streetwriters/notesnook">open source</a>.',
|
||||
copyright: "Copyright © 2026 Streetwriters (Private) Limited"
|
||||
},
|
||||
|
||||
docFooter: { prev: "Previous", next: "Next" },
|
||||
externalLinkIcon: true,
|
||||
returnToTopLabel: "Back to top",
|
||||
darkModeSwitchLabel: "Appearance"
|
||||
}
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* Per-page SEO: canonical URL, Open Graph, Twitter cards and JSON-LD.
|
||||
*
|
||||
* The help site ranks #1 for high-intent queries like "import enex", so every
|
||||
* page needs to be individually addressable, individually described, and
|
||||
* eligible for rich results. Driven from each page's frontmatter:
|
||||
*
|
||||
* ---
|
||||
* title: Import from Evernote # sidebar label
|
||||
* description: One sentence… # meta description + search snippet
|
||||
* pageTitle: How to import Evernote… # optional: overrides the <title> only
|
||||
* keywords: [import enex, evernote…] # optional
|
||||
* schema: howto | faq | article # optional, default article
|
||||
* faqs: # required when schema: faq
|
||||
* - q: …
|
||||
* a: …
|
||||
* ---
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { HeadConfig, TransformPageContext, PageData } from "vitepress";
|
||||
import { resolveString } from "./strings.mjs";
|
||||
|
||||
const SITE = "https://notesnook.com/help";
|
||||
const OG_IMAGE = `${SITE}/logo.png`;
|
||||
|
||||
const url = (relativePath: string) =>
|
||||
`${SITE}/${relativePath
|
||||
.replace(/(index)?\.md$/, "")
|
||||
.replace(/\/$/, "")}`.replace(/\/$/, "") || SITE;
|
||||
|
||||
/** "organizing-notes/archive-notes.md" -> ["Organizing notes", "Archive notes"] */
|
||||
function breadcrumbs(relativePath: string, title: string) {
|
||||
const parts = relativePath.split("/").slice(0, -1);
|
||||
const crumbs = [{ name: "Notesnook Help", item: SITE }];
|
||||
let path = "";
|
||||
for (const part of parts) {
|
||||
path += `/${part}`;
|
||||
crumbs.push({
|
||||
name: part.replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase()),
|
||||
item: `${SITE}${path}`
|
||||
});
|
||||
}
|
||||
crumbs.push({ name: title, item: url(relativePath) });
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's markdown. `transformPageData`'s context does not carry the source,
|
||||
* so it is read back off disk.
|
||||
*/
|
||||
function pageSource(relativePath: string) {
|
||||
try {
|
||||
return readFileSync(join(process.cwd(), "contents", relativePath), "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Numbered list items in the first tab of a page become HowTo steps. */
|
||||
const STRING_TOKEN = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)(?::(\d+))?\s*\}\}/g;
|
||||
|
||||
function howToSteps(src: string) {
|
||||
const steps: { name: string; text: string }[] = [];
|
||||
for (const line of src.split("\n")) {
|
||||
const m = line.match(/^\s*\d+\.\s+(.*\S)\s*$/);
|
||||
if (!m) continue;
|
||||
const text = m[1]
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
// These steps come from the raw markdown, before the markdown-it plugin
|
||||
// has swapped `{{key}}` for the app's label — resolve them here too, or
|
||||
// the structured data Google reads ships the raw tokens.
|
||||
.replace(STRING_TOKEN, (_m, key: string, count?: string) =>
|
||||
resolveString(key, count ? Number(count) : undefined)
|
||||
)
|
||||
.replace(/[`*_]/g, "")
|
||||
.trim();
|
||||
if (text.length > 3) steps.push({ name: text.slice(0, 110), text });
|
||||
if (steps.length >= 12) break;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function jsonLd(pageData: PageData, ctx: TransformPageContext) {
|
||||
const fm = pageData.frontmatter;
|
||||
const title = (fm.pageTitle || fm.title || pageData.title) as string;
|
||||
const description = (fm.description || "") as string;
|
||||
const pageUrl = url(pageData.relativePath);
|
||||
const graph: Record<string, unknown>[] = [];
|
||||
|
||||
graph.push({
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: breadcrumbs(pageData.relativePath, title).map((c, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: c.name,
|
||||
item: c.item
|
||||
}))
|
||||
});
|
||||
|
||||
const publisher = {
|
||||
"@type": "Organization",
|
||||
name: "Notesnook",
|
||||
url: "https://notesnook.com",
|
||||
logo: OG_IMAGE
|
||||
};
|
||||
|
||||
if (fm.schema === "faq" && Array.isArray(fm.faqs) && fm.faqs.length) {
|
||||
graph.push({
|
||||
"@type": "FAQPage",
|
||||
mainEntity: fm.faqs.map((f: { q: string; a: string }) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a }
|
||||
}))
|
||||
});
|
||||
} else if (fm.schema === "howto") {
|
||||
const steps = howToSteps(pageSource(pageData.relativePath));
|
||||
if (steps.length)
|
||||
graph.push({
|
||||
"@type": "HowTo",
|
||||
name: title,
|
||||
description,
|
||||
url: pageUrl,
|
||||
step: steps.map((s, i) => ({
|
||||
"@type": "HowToStep",
|
||||
position: i + 1,
|
||||
name: s.name,
|
||||
text: s.text,
|
||||
url: `${pageUrl}#${i + 1}`
|
||||
})),
|
||||
tool: [{ "@type": "HowToTool", name: "Notesnook" }],
|
||||
totalTime: fm.totalTime || undefined
|
||||
});
|
||||
}
|
||||
|
||||
graph.push({
|
||||
"@type": "TechArticle",
|
||||
headline: title,
|
||||
description,
|
||||
url: pageUrl,
|
||||
inLanguage: "en",
|
||||
isPartOf: {
|
||||
"@type": "WebSite",
|
||||
name: "Notesnook Help",
|
||||
url: SITE
|
||||
},
|
||||
about: {
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Notesnook",
|
||||
applicationCategory: "ProductivityApplication",
|
||||
operatingSystem: "Windows, macOS, Linux, Android, iOS, Web"
|
||||
},
|
||||
author: publisher,
|
||||
publisher,
|
||||
dateModified: pageData.lastUpdated
|
||||
? new Date(pageData.lastUpdated).toISOString()
|
||||
: undefined
|
||||
});
|
||||
|
||||
return JSON.stringify({ "@context": "https://schema.org", "@graph": graph });
|
||||
}
|
||||
|
||||
/**
|
||||
* Head tags for one page. Returned as frontmatter `head` entries so VitePress
|
||||
* merges them into the rendered <head>.
|
||||
*/
|
||||
export function seoHead(
|
||||
pageData: PageData,
|
||||
ctx: TransformPageContext
|
||||
): HeadConfig[] {
|
||||
const fm = pageData.frontmatter;
|
||||
if (fm.layout === "home" && !fm.description) return [];
|
||||
|
||||
const title = (fm.pageTitle || fm.title || pageData.title) as string;
|
||||
const description = (fm.description || "") as string;
|
||||
const pageUrl = url(pageData.relativePath);
|
||||
const fullTitle = fm.pageTitle
|
||||
? `${fm.pageTitle} | Notesnook Help`
|
||||
: `${title} | Notesnook Help`;
|
||||
|
||||
const head: HeadConfig[] = [
|
||||
["link", { rel: "canonical", href: pageUrl }],
|
||||
["meta", { property: "og:title", content: fullTitle }],
|
||||
["meta", { property: "og:description", content: description }],
|
||||
["meta", { property: "og:url", content: pageUrl }],
|
||||
["meta", { property: "og:image", content: OG_IMAGE }],
|
||||
["meta", { name: "twitter:card", content: "summary" }],
|
||||
["meta", { name: "twitter:title", content: fullTitle }],
|
||||
["meta", { name: "twitter:description", content: description }]
|
||||
];
|
||||
|
||||
if (Array.isArray(fm.keywords) && fm.keywords.length)
|
||||
head.push(["meta", { name: "keywords", content: fm.keywords.join(", ") }]);
|
||||
|
||||
head.push(["script", { type: "application/ld+json" }, jsonLd(pageData, ctx)]);
|
||||
|
||||
return head;
|
||||
}
|
||||
|
||||
/** The <title> tag: prefer an SEO-shaped `pageTitle` when the page defines one. */
|
||||
export function seoTitle(pageData: PageData) {
|
||||
if (pageData.frontmatter.pageTitle)
|
||||
pageData.title = pageData.frontmatter.pageTitle as string;
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
/**
|
||||
* Help site navigation.
|
||||
*
|
||||
* A page that is not listed here is unreachable from the sidebar, so every new
|
||||
* article needs an entry. `link` values are extensionless and root-absolute —
|
||||
* they mirror the file path under `contents/`, which is also the public URL.
|
||||
*/
|
||||
export const sidebar = [
|
||||
{
|
||||
text: "Getting started",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "All help topics", link: "/docs" },
|
||||
{ text: "Create your first note", link: "/create-a-note-in-notesnook" },
|
||||
{ text: "Search & navigation", link: "/search-and-navigation" },
|
||||
{ text: "Keyboard shortcuts", link: "/keyboard-shortcuts" },
|
||||
{ text: "Plans & limits", link: "/plans-and-limits" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Organizing notes",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Notebooks",
|
||||
link: "/organizing-notes/organize-notes-using-notebooks"
|
||||
},
|
||||
{ text: "Tags", link: "/organizing-notes/organize-notes-using-tags" },
|
||||
{ text: "Colors", link: "/organizing-notes/organize-notes-using-colors" },
|
||||
{
|
||||
text: "Favorites",
|
||||
link: "/organizing-notes/organize-notes-using-favorites"
|
||||
},
|
||||
{ text: "Pins", link: "/organizing-notes/pin-notes" },
|
||||
{ text: "Archive", link: "/organizing-notes/archive-notes" },
|
||||
{
|
||||
text: "Side menu shortcuts",
|
||||
link: "/organizing-notes/side-menu-shortcuts"
|
||||
},
|
||||
{ text: "Reminders", link: "/reminders" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Working with notes",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Note actions", link: "/notes/note-actions" },
|
||||
{ text: "Note links", link: "/note-links-and-backlinks" },
|
||||
{ text: "Expiring notes", link: "/notes/note-expiry" },
|
||||
{ text: "Version history", link: "/note-version-history" },
|
||||
{ text: "Trash", link: "/trash" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Editor",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Editor toolbar",
|
||||
link: "/rich-text-editor/rich-text-editor-toolbar"
|
||||
},
|
||||
{ text: "Tabs & panes", link: "/rich-text-editor/editor-tabs-and-panes" },
|
||||
{
|
||||
text: "Personalizing the editor",
|
||||
link: "/rich-text-editor/personalizing-rich-text-editor"
|
||||
},
|
||||
{
|
||||
text: "Markdown shortcuts",
|
||||
link: "/rich-text-editor/markdown-notes-editing"
|
||||
},
|
||||
{
|
||||
text: "Headings",
|
||||
link: "/rich-text-editor/headings-and-collapsible-sections"
|
||||
},
|
||||
{ text: "Tables", link: "/rich-text-editor/tables" },
|
||||
{ text: "Task lists", link: "/rich-text-editor/task-and-todo-lists" },
|
||||
{ text: "Outline lists", link: "/rich-text-editor/outline-lists" },
|
||||
{ text: "Callouts", link: "/rich-text-editor/callouts" },
|
||||
{ text: "Code blocks", link: "/rich-text-editor/code-blocks" },
|
||||
{ text: "Math & formulas", link: "/rich-text-editor/math-and-formulas" },
|
||||
{
|
||||
text: "Images & embeds",
|
||||
link: "/rich-text-editor/images-attachments-and-embeds"
|
||||
},
|
||||
{ text: "Find & replace", link: "/rich-text-editor/search-and-replace" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Importing notes",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Overview", link: "/importing-notes/" },
|
||||
{ text: "Evernote", link: "/importing-notes/import-notes-from-evernote" },
|
||||
{
|
||||
text: "Google Keep",
|
||||
link: "/importing-notes/import-notes-from-googlekeep"
|
||||
},
|
||||
{ text: "Joplin", link: "/importing-notes/import-notes-from-joplin" },
|
||||
{ text: "Obsidian", link: "/importing-notes/import-notes-from-obsidian" },
|
||||
{
|
||||
text: "Simplenote",
|
||||
link: "/importing-notes/import-notes-from-simplenote"
|
||||
},
|
||||
// Standard Notes is unpublished for now; the page is excluded from the
|
||||
// build in config.mts. Restore this entry when it goes live again.
|
||||
// {
|
||||
// text: "Standard Notes",
|
||||
// link: "/importing-notes/import-notes-from-standardnotes"
|
||||
// },
|
||||
{
|
||||
text: "ColorNote",
|
||||
link: "/importing-notes/import-notes-from-colornote"
|
||||
},
|
||||
{ text: "UpNote", link: "/importing-notes/import-notes-from-upnote" },
|
||||
{
|
||||
text: "Skiff Pages",
|
||||
link: "/importing-notes/import-notes-from-skiff-pages"
|
||||
},
|
||||
{
|
||||
text: "Zoho Notebook",
|
||||
link: "/importing-notes/import-notes-from-zoho-notebook"
|
||||
},
|
||||
{
|
||||
text: "Fusebase (Nimbus Note)",
|
||||
link: "/importing-notes/import-notes-from-fusebase"
|
||||
},
|
||||
{
|
||||
text: "Markdown files",
|
||||
link: "/importing-notes/import-notes-from-markdown-files"
|
||||
},
|
||||
{
|
||||
text: "HTML files",
|
||||
link: "/importing-notes/import-notes-from-html-files"
|
||||
},
|
||||
{
|
||||
text: "Plaintext files",
|
||||
link: "/importing-notes/import-notes-from-plaintext-files"
|
||||
},
|
||||
{
|
||||
text: "TextBundle files",
|
||||
link: "/importing-notes/import-notes-from-textbundle-files"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Backup & export",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Backup and restore",
|
||||
link: "/backup-and-restore-notes-in-notesnook"
|
||||
},
|
||||
{ text: "Exporting notes", link: "/export-notes-from-notesnook" },
|
||||
{ text: "Attachments & files", link: "/attachments-and-files" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Sync",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "How sync works", link: "/sync/how-sync-works" },
|
||||
{ text: "Sync settings", link: "/sync/sync-settings" },
|
||||
{ text: "Troubleshooting sync", link: "/sync/troubleshooting-sync" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Privacy & security",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "How is my data encrypted?", link: "/how-is-my-data-encrypted" },
|
||||
{ text: "Private vault", link: "/lock-notes-with-private-vault" },
|
||||
{ text: "App lock", link: "/app-lock" },
|
||||
{ text: "Two-factor authentication", link: "/two-factor-authentication" },
|
||||
{ text: "Privacy mode", link: "/privacy-mode" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Publishing",
|
||||
collapsed: false,
|
||||
items: [{ text: "Monographs", link: "/publish-notes-with-monographs" }]
|
||||
},
|
||||
{
|
||||
text: "Web clipper",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Installation", link: "/web-clipper/installation" },
|
||||
{
|
||||
text: "Clipping your first page",
|
||||
link: "/web-clipper/clipping-your-first-web-page-with-web-clipper"
|
||||
},
|
||||
{ text: "Troubleshooting", link: "/web-clipper/troubleshooting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Mobile",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Home screen widgets",
|
||||
link: "/mobile-integration/home-screen-widgets"
|
||||
},
|
||||
{
|
||||
text: "Android quick actions",
|
||||
link: "/mobile-integration/android-quick-actions"
|
||||
},
|
||||
{
|
||||
text: "Pin to notifications",
|
||||
link: "/mobile-integration/pin-notes-to-notifications"
|
||||
},
|
||||
{
|
||||
text: "Quick notes",
|
||||
link: "/mobile-integration/quick-note-from-notification"
|
||||
},
|
||||
{
|
||||
text: "Share from other apps",
|
||||
link: "/mobile-integration/share-things-from-other-apps"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Desktop",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Auto start",
|
||||
link: "/desktop-integration/auto-start-on-system-startup"
|
||||
},
|
||||
{
|
||||
text: "System tray menu",
|
||||
link: "/desktop-integration/system-tray-menu"
|
||||
},
|
||||
{
|
||||
text: "Jumplist & dock menu",
|
||||
link: "/desktop-integration/jumplist-and-dock-menu"
|
||||
},
|
||||
{ text: "Spell checker", link: "/desktop-integration/spell-checker" },
|
||||
{
|
||||
text: "Updates & advanced",
|
||||
link: "/desktop-integration/updates-and-advanced-settings"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Appearance & themes",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Customizing the app", link: "/customizing-notesnook" },
|
||||
{
|
||||
text: "Using themes",
|
||||
link: "/custom-themes/using-themes",
|
||||
items: [
|
||||
{ text: "How themes work", link: "/custom-themes/introduction" },
|
||||
{
|
||||
text: "Theme Builder",
|
||||
link: "/custom-themes/create-a-theme-with-theme-builder"
|
||||
},
|
||||
{
|
||||
text: "Install from file",
|
||||
link: "/custom-themes/install-a-theme-from-file"
|
||||
},
|
||||
{
|
||||
text: "Publish a new theme",
|
||||
link: "/custom-themes/publish-a-theme"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Your account",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Account settings", link: "/account-settings" },
|
||||
{ text: "Notesnook Circle", link: "/notesnook-circle" },
|
||||
{ text: "Recovering your account", link: "/recovering-your-account" },
|
||||
{ text: "Deleting your account", link: "/deleting-your-account" },
|
||||
{ text: "Gift cards", link: "/gift-cards" },
|
||||
{ text: "Notesnook Wrapped", link: "/notesnook-wrapped" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "Advanced",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Inbox API",
|
||||
items: [
|
||||
{
|
||||
text: "Getting started",
|
||||
link: "/inbox-api/getting-started"
|
||||
},
|
||||
{
|
||||
text: "Self-hosting the Inbox API",
|
||||
link: "/inbox-api/self-hosting-inbox-api"
|
||||
}
|
||||
]
|
||||
}
|
||||
// { text: "Self-hosting Notesnook", link: "/self-hosting" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "FAQs",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "What are merge conflicts?",
|
||||
link: "/faqs/what-are-merge-conflicts"
|
||||
},
|
||||
{ text: "Is there an ETA for X feature?", link: "/faqs/is-there-an-eta" },
|
||||
{
|
||||
text: "Why login is needed to upload attachments",
|
||||
link: "/faqs/login-to-upload-attachments"
|
||||
},
|
||||
{
|
||||
text: "Why login is needed to restore attachments",
|
||||
link: "/faqs/login-to-restore-attachments-in-backup"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* Live UI strings, straight from the app.
|
||||
*
|
||||
* The docs quote hundreds of button and menu labels. Typing them by hand means
|
||||
* they rot the moment someone renames a string, so pages write a key instead:
|
||||
*
|
||||
* Click on `{{archive}}` -> Click on `Archive`
|
||||
*
|
||||
* The key is resolved at build time from `@notesnook/intl` — the same catalogue
|
||||
* the apps render from — so renaming a string in the app updates every page that
|
||||
* quotes it on the next build. An unknown key fails the build rather than
|
||||
* shipping a placeholder.
|
||||
*
|
||||
* This only *reads* the catalogue. Never add strings to `packages/intl` for the
|
||||
* docs' sake: if a label has no string, write it as plain text and say why.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { strings, setI18nGlobal } from "@notesnook/intl";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// The compiled English catalogue lives beside the package's dist output.
|
||||
const localePath = require.resolve("@notesnook/intl/locales/$en.json");
|
||||
const locale = JSON.parse(readFileSync(localePath, "utf8"));
|
||||
i18n.load({ en: locale.messages });
|
||||
i18n.activate("en");
|
||||
setI18nGlobal(i18n);
|
||||
|
||||
export type StringKey = keyof typeof strings;
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Resolve one key to the English text the app shows.
|
||||
*
|
||||
* A few catalogue entries are plural forms that take a count — quote those as
|
||||
* `{{notebooks:2}}` and the number is passed through.
|
||||
*/
|
||||
export function resolveString(key: string, count?: number): string {
|
||||
const cacheKey = count === undefined ? key : `${key}:${count}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const entry = (strings as Record<string, unknown>)[key];
|
||||
if (typeof entry !== "function")
|
||||
throw new Error(
|
||||
`Unknown UI string "${key}". It must be an existing key in packages/intl ` +
|
||||
`(see strings.ts). Do not invent one — write the label as plain text instead.`
|
||||
);
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value =
|
||||
count === undefined
|
||||
? (entry as () => unknown)()
|
||||
: (entry as (n: number) => unknown)(count);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`UI string "${key}" needs arguments. If it is a plural, quote it as ` +
|
||||
`{{${key}:2}}; otherwise write the label as plain text.`
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value !== "string" || !value.trim())
|
||||
throw new Error(`UI string "${key}" did not resolve to text.`);
|
||||
|
||||
cache.set(cacheKey, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse index: rendered text -> the key(s) that produce it. Used by
|
||||
* `scripts/check-strings.mjs` to find hardcoded labels that could be keys.
|
||||
*/
|
||||
export function buildReverseIndex(): Map<string, string[]> {
|
||||
const index = new Map<string, string[]>();
|
||||
for (const key of Object.keys(strings)) {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = (strings as Record<string, () => unknown>)[key]();
|
||||
} catch {
|
||||
continue; // needs arguments
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) continue;
|
||||
const existing = index.get(value);
|
||||
if (existing) existing.push(key);
|
||||
else index.set(value, [key]);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Every key used across the docs this build, for reporting. */
|
||||
export const usedKeys = new Set<string>();
|
||||
|
||||
const TOKEN = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)(?::(\d+))?\s*\}\}/g;
|
||||
|
||||
/**
|
||||
* markdown-it rule: swap `{{key}}` for the live string while parsing, so the
|
||||
* rendered HTML contains real text and Vue never sees a moustache.
|
||||
*/
|
||||
export function stringsMarkdownPlugin(md: any) {
|
||||
md.core.ruler.push("nn_ui_strings", (state: any) => {
|
||||
const where = state.env?.relativePath ? ` in ${state.env.relativePath}` : "";
|
||||
const swap = (text: string) =>
|
||||
text.replace(TOKEN, (_match: string, key: string, count?: string) => {
|
||||
try {
|
||||
const value = resolveString(key, count ? Number(count) : undefined);
|
||||
usedKeys.add(key);
|
||||
return value;
|
||||
} catch (error) {
|
||||
throw new Error((error as Error).message + where);
|
||||
}
|
||||
});
|
||||
|
||||
for (const token of state.tokens) {
|
||||
if (token.type === "inline" && token.children) {
|
||||
for (const child of token.children) {
|
||||
if (child.type === "text" || child.type === "code_inline")
|
||||
child.content = swap(child.content);
|
||||
}
|
||||
} else if (token.type === "fence" || token.type === "html_block") {
|
||||
// Leave code fences alone; a doc may legitimately show `{{ }}` syntax.
|
||||
continue;
|
||||
}
|
||||
if (token.type === "inline") token.content = swap(token.content);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Every page on the site, grouped exactly as the sidebar groups them.
|
||||
*
|
||||
* The home page has no sidebar, so without this there is no way to see what the
|
||||
* documentation actually covers. Reads the same sidebar module the site is
|
||||
* built from, so it can never drift from the navigation.
|
||||
*/
|
||||
import { sidebar } from "../../sidebar.mjs";
|
||||
import { withBase } from "vitepress";
|
||||
|
||||
type Item = { text: string; link?: string; items?: Item[] };
|
||||
|
||||
// Drop this page's own entry — listing the index inside the index is noise.
|
||||
const groups = (sidebar as Item[]).map((group) => ({
|
||||
...group,
|
||||
items: group.items?.filter((item) => item.link !== "/docs")
|
||||
}));
|
||||
|
||||
const pageCount = groups.reduce(
|
||||
(total, group) => total + (group.items?.filter((i) => i.link).length ?? 0),
|
||||
0
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nn-index">
|
||||
<p class="nn-index__count">
|
||||
{{ pageCount }} pages, grouped by what you're trying to do.
|
||||
</p>
|
||||
<div class="nn-index__grid">
|
||||
<section
|
||||
v-for="group in groups"
|
||||
:key="group.text"
|
||||
class="nn-index__group"
|
||||
>
|
||||
<h2 class="nn-index__heading">{{ group.text }}</h2>
|
||||
<ul class="nn-index__list">
|
||||
<li v-for="item in group.items" :key="item.link || item.text">
|
||||
<a v-if="item.link" :href="withBase(item.link)">{{ item.text }}</a>
|
||||
<span v-else>{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nn-index__count {
|
||||
margin: 0 0 28px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.nn-index__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 28px 32px;
|
||||
}
|
||||
|
||||
.nn-index__group {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.nn-index__heading {
|
||||
margin: 0 0 10px;
|
||||
padding: 0 0 8px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.nn-index__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nn-index__list li {
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nn-index__list a {
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nn-index__list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Conversion block for high-intent pages (importers, comparisons, "how do I…"
|
||||
* pages that people land on from search). Renders real anchors so crawlers and
|
||||
* no-JS clients follow them.
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
text?: string;
|
||||
/** Primary link target: "download" | "pricing" */
|
||||
action?: string;
|
||||
}>(),
|
||||
{
|
||||
title: "Ready to move your notes?",
|
||||
text: "Notesnook is free to use, end-to-end encrypted by default, and open source. Install it on every device you own and your notes stay in sync — readable only by you.",
|
||||
action: "download"
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="nn-cta">
|
||||
<p class="nn-cta__title">{{ title }}</p>
|
||||
<p class="nn-cta__text">{{ text }}</p>
|
||||
<p class="nn-cta__actions">
|
||||
<a
|
||||
v-if="action === 'download'"
|
||||
class="nn-cta__button"
|
||||
href="https://notesnook.com/downloads"
|
||||
>Download Notesnook</a
|
||||
>
|
||||
<a
|
||||
v-else
|
||||
class="nn-cta__button"
|
||||
href="https://notesnook.com/pricing"
|
||||
>See plans and pricing</a
|
||||
>
|
||||
<a class="nn-cta__link" href="/plans-and-limits">What's included in each plan</a>
|
||||
</p>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nn-cta {
|
||||
margin: 32px 0;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-left: 3px solid var(--nn-accent);
|
||||
border-radius: var(--nn-radius-large);
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.nn-cta__title {
|
||||
margin: 0 0 6px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.nn-cta__text {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.nn-cta__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nn-cta__button {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--nn-radius-button);
|
||||
background-color: var(--nn-accent);
|
||||
color: var(--nn-accent-foreground) !important;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none !important;
|
||||
transition: background-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.nn-cta__button:hover {
|
||||
background-color: #008837e6;
|
||||
}
|
||||
|
||||
.nn-cta__link {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -1,118 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
const isMac = ref(false);
|
||||
onMounted(() => {
|
||||
isMac.value = /mac/i.test(navigator.platform || navigator.userAgent);
|
||||
});
|
||||
|
||||
/**
|
||||
* Open the site's own search modal. VitePress listens for a Cmd/Ctrl+K keydown
|
||||
* on `window` and its nav button triggers search by dispatching exactly this
|
||||
* synthetic event, so we reuse that path rather than reimplementing search.
|
||||
*/
|
||||
function openSearch() {
|
||||
const event = new Event("keydown") as Event & { key: string; metaKey: boolean };
|
||||
event.key = "k";
|
||||
event.metaKey = true;
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nn-home-search">
|
||||
<button
|
||||
type="button"
|
||||
class="nn-home-search__button"
|
||||
aria-label="Search the documentation"
|
||||
@click="openSearch"
|
||||
>
|
||||
<span class="nn-home-search__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path
|
||||
d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="nn-home-search__placeholder">Search the docs…</span>
|
||||
<kbd class="nn-home-search__key">{{ isMac ? "⌘" : "Ctrl" }} K</kbd>
|
||||
</button>
|
||||
<p class="nn-home-search__hint">
|
||||
Try “import from Evernote”, “app lock”, or “why is my note not syncing”.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nn-home-search {
|
||||
max-width: 640px;
|
||||
/* The hero's own bottom padding stops here, so the space below the search box
|
||||
has to come from this margin — without it the features grid rides up over
|
||||
the hint text. */
|
||||
margin: 16px auto 56px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nn-home-search {
|
||||
margin: 8px auto 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.nn-home-search__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1.5px solid var(--vp-c-divider);
|
||||
border-radius: var(--nn-radius-button, 10px);
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
cursor: text;
|
||||
transition: border-color 120ms ease-out, box-shadow 120ms ease-out;
|
||||
}
|
||||
|
||||
.nn-home-search__button:hover,
|
||||
.nn-home-search__button:focus-visible {
|
||||
border-color: var(--nn-accent);
|
||||
box-shadow: 0 0 0 3px var(--vp-c-brand-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nn-home-search__icon {
|
||||
display: flex;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.nn-home-search__placeholder {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nn-home-search__key {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: var(--nn-radius-default, 5px);
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.nn-home-search__hint {
|
||||
margin: 10px 2px 0;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nn-home-search__key {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
/** essential | pro | believer | free — the LOWEST plan that unlocks the feature. */
|
||||
plan: string;
|
||||
/** Set when the feature is limited to one platform, e.g. "Android only". */
|
||||
note?: string;
|
||||
}>();
|
||||
|
||||
const PLANS: Record<string, { label: string; title: string }> = {
|
||||
free: {
|
||||
label: "Free",
|
||||
title: "Available on every plan, including Free"
|
||||
},
|
||||
essential: {
|
||||
label: "Essential",
|
||||
title: "Requires the Essential plan or higher (Essential, Pro, Believer)"
|
||||
},
|
||||
pro: {
|
||||
label: "Pro",
|
||||
title: "Requires the Pro plan or higher (Pro, Believer)"
|
||||
},
|
||||
believer: {
|
||||
label: "Believer",
|
||||
title: "Requires the Believer plan"
|
||||
}
|
||||
};
|
||||
|
||||
const tier = computed(() => PLANS[props.plan.toLowerCase()] ?? PLANS.pro);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="nn-plan-tag ignore-header" :class="`nn-plan-tag--${plan.toLowerCase()}`" :title="tier.title">
|
||||
{{ tier.label }}
|
||||
<span v-if="note" class="nn-plan-tag__note">· {{ note }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nn-plan-tag {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 100px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1.7;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.nn-plan-tag__note {
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.nn-plan-tag--free {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-color: var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.nn-plan-tag--essential,
|
||||
.nn-plan-tag--pro,
|
||||
.nn-plan-tag--believer {
|
||||
background-color: var(--vp-c-brand-soft);
|
||||
border-color: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.nn-plan-tag--believer {
|
||||
background-color: transparent;
|
||||
border-color: var(--nn-accent);
|
||||
}
|
||||
|
||||
h1 .nn-plan-tag,
|
||||
h2 .nn-plan-tag,
|
||||
h3 .nn-plan-tag {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useData } from "vitepress";
|
||||
|
||||
const { frontmatter, page } = useData();
|
||||
|
||||
const archived = computed(() => frontmatter.value.archivedVersion as string | undefined);
|
||||
const latest = computed(() => frontmatter.value.latestVersion as string | undefined);
|
||||
|
||||
// The same article in the latest docs, if it still exists there.
|
||||
const latestLink = computed(() => {
|
||||
const path = page.value.relativePath
|
||||
.replace(/^v[\d.]+\//, "/")
|
||||
.replace(/(index)?\.md$/, "");
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="archived" class="nn-version-banner">
|
||||
<p>
|
||||
You are reading the documentation for <strong>Notesnook v{{ archived }}</strong>.
|
||||
The current version is v{{ latest }}.
|
||||
</p>
|
||||
<a :href="latestLink">Read the latest version of this page →</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nn-version-banner {
|
||||
margin-bottom: 24px;
|
||||
padding: 15px 20px;
|
||||
border: 1px solid var(--vp-c-warning-soft);
|
||||
border-left: 3px solid var(--vp-c-warning-1);
|
||||
border-radius: var(--nn-radius-large);
|
||||
background-color: var(--vp-custom-block-warning-bg);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.nn-version-banner p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.nn-version-banner a {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nn-version-banner a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Self-hosted webfonts, matching the Notesnook app.
|
||||
* Inter is the app's UI font (apps/web/src/app.css), Fira Code its code font.
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local(""), url("/fonts/Inter-Regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: local(""), url("/fonts/Inter-Medium.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: local(""), url("/fonts/Inter-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: local(""), url("/fonts/Inter-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local(""), url("/fonts/Inter-Italic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: local(""), url("/fonts/Inter-MediumItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: local(""), url("/fonts/Inter-SemiBoldItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: local(""), url("/fonts/Inter-BoldItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local(""), url("/fonts/fira-code-v21-latin-regular.woff2")
|
||||
format("woff2");
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Theme } from "vitepress";
|
||||
// theme-without-fonts skips the default theme's own bundled Inter — we ship the
|
||||
// exact Inter files the Notesnook app uses instead (see fonts.css).
|
||||
import DefaultTheme from "vitepress/theme-without-fonts";
|
||||
import { enhanceAppWithTabs } from "vitepress-plugin-tabs/client";
|
||||
import { h } from "vue";
|
||||
import VersionBanner from "./components/VersionBanner.vue";
|
||||
import PlanTag from "./components/PlanTag.vue";
|
||||
import GetNotesnook from "./components/GetNotesnook.vue";
|
||||
import HomeSearch from "./components/HomeSearch.vue";
|
||||
import DocsIndex from "./components/DocsIndex.vue";
|
||||
import "./fonts.css";
|
||||
import "./notesnook.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: () =>
|
||||
h(DefaultTheme.Layout, null, {
|
||||
// Renders only on pages under an archived /v<version>/ tree.
|
||||
"doc-before": () => h(VersionBanner),
|
||||
// The home page has no sidebar, so search is the primary way in.
|
||||
"home-hero-after": () => h(HomeSearch)
|
||||
}),
|
||||
enhanceApp({ app }) {
|
||||
enhanceAppWithTabs(app);
|
||||
// Usable directly in markdown, no per-page import.
|
||||
app.component("PlanTag", PlanTag);
|
||||
app.component("GetNotesnook", GetNotesnook);
|
||||
app.component("DocsIndex", DocsIndex);
|
||||
}
|
||||
} satisfies Theme;
|
||||
@@ -1,628 +0,0 @@
|
||||
/**
|
||||
* Notesnook design tokens applied to VitePress.
|
||||
*
|
||||
* Values come from @notesnook/theme (default-light / default-dark v2.1) and
|
||||
* apps/web:
|
||||
* accent #008837 packages/theme default themes
|
||||
* radii 2.5 / 5 / 7 / 10 packages/theme/src/theme/index.ts
|
||||
* space scale 6 / 10 / 15 / 20 packages/theme/src/theme/index.ts
|
||||
* shadows menu / dialog packages/theme/src/theme/index.ts
|
||||
* fonts Inter, Fira Code apps/web/src/app.css
|
||||
*
|
||||
* Notesnook's UI is flat: 5px radii, hairline borders, hover fills instead of
|
||||
* cards, and a single accent used sparingly. This file keeps VitePress in that
|
||||
* register rather than layering a second design language on top of it.
|
||||
*/
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Tokens */
|
||||
/* ========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Notesnook primitives */
|
||||
--nn-accent: #008837;
|
||||
--nn-accent-foreground: #ffffff;
|
||||
--nn-shade: #0088371a; /* alpha(accent, .1) */
|
||||
|
||||
--nn-radius-small: 2.5px;
|
||||
--nn-radius-default: 5px;
|
||||
--nn-radius-large: 7px;
|
||||
--nn-radius-dialog: 10px;
|
||||
--nn-radius-button: 10px;
|
||||
|
||||
--nn-space-1: 6px;
|
||||
--nn-space-2: 10px;
|
||||
--nn-space-3: 15px;
|
||||
--nn-space-4: 20px;
|
||||
--nn-space-5: 25px;
|
||||
|
||||
--nn-static-orange: #ff9800;
|
||||
|
||||
/* Typography */
|
||||
--vp-font-family-base: "Inter", "Noto Sans", Frutiger, Calibri, Myriad, Arial,
|
||||
Ubuntu, Helvetica, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--vp-font-family-mono: "Fira Code", "Fira Mono", Hack, Menlo, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
|
||||
/* Layout — a help site reads better slightly narrower than VitePress' default */
|
||||
--vp-layout-max-width: 1440px;
|
||||
--vp-sidebar-width: 288px;
|
||||
--vp-nav-height: 60px;
|
||||
}
|
||||
|
||||
/* ------------------------------- light ---------------------------------- */
|
||||
:root {
|
||||
--vp-c-brand-1: #008837;
|
||||
--vp-c-brand-2: #008837e6; /* app button hover — alpha(accent, .9) */
|
||||
--vp-c-brand-3: #008837cc; /* app button active — alpha(accent, .8) */
|
||||
--vp-c-brand-soft: var(--nn-shade);
|
||||
|
||||
--vp-c-bg: #ffffff;
|
||||
--vp-c-bg-alt: #f7f7f7;
|
||||
--vp-c-bg-soft: #f7f7f7;
|
||||
--vp-c-bg-elv: #ffffff;
|
||||
|
||||
--vp-c-divider: #e8e8e8;
|
||||
--vp-c-border: #e8e8e8;
|
||||
--vp-c-gutter: #e8e8e8;
|
||||
|
||||
--vp-c-text-1: #202020; /* heading */
|
||||
--vp-c-text-2: #505050; /* paragraph */
|
||||
--vp-c-text-3: #777777; /* paragraph-secondary */
|
||||
|
||||
--nn-hover: #eeeeee;
|
||||
--nn-selected-bg: #eeeeee;
|
||||
--nn-selected-fg: #212121;
|
||||
--nn-code-bg: #f7f7f7;
|
||||
--nn-shadow-menu: 0px 0px 10px 0px #00000022;
|
||||
--nn-shadow-dialog: 0px 0px 25px 5px #0000004e;
|
||||
|
||||
--vp-c-tip-1: #4f8a10;
|
||||
--vp-c-tip-soft: #4f8a101a;
|
||||
--vp-c-danger-1: #f54b42;
|
||||
--vp-c-danger-soft: #f54b421a;
|
||||
--vp-c-warning-1: #b26a00; /* darkened static orange for AA on white */
|
||||
--vp-c-warning-soft: #ff98001f;
|
||||
}
|
||||
|
||||
/* -------------------------------- dark ---------------------------------- */
|
||||
.dark {
|
||||
/* #008837 is only 3.06:1 on the app's #181818, so text-bearing brand steps
|
||||
are lightened for readability. Solid accent fills stay exactly #008837. */
|
||||
--vp-c-brand-1: #00b34a;
|
||||
--vp-c-brand-2: #00c853;
|
||||
--vp-c-brand-3: #008837;
|
||||
--vp-c-brand-soft: #00883733;
|
||||
|
||||
--vp-c-bg: #181818;
|
||||
--vp-c-bg-alt: #202020;
|
||||
--vp-c-bg-soft: #202020;
|
||||
--vp-c-bg-elv: #202020;
|
||||
|
||||
--vp-c-divider: #383838;
|
||||
--vp-c-border: #2b2b2b;
|
||||
--vp-c-gutter: #2b2b2b;
|
||||
|
||||
--vp-c-text-1: #e3e3e3;
|
||||
--vp-c-text-2: #d3d3d3;
|
||||
--vp-c-text-3: #818589;
|
||||
|
||||
--nn-hover: #2b2b2b;
|
||||
--nn-selected-bg: #494949;
|
||||
--nn-selected-fg: #fbfbfb;
|
||||
--nn-code-bg: #202020;
|
||||
--nn-shadow-menu: 0px 0px 10px 0px #00000078;
|
||||
--nn-shadow-dialog: 0px 0px 25px 5px #000000aa;
|
||||
|
||||
--vp-c-tip-1: #7bb32e;
|
||||
--vp-c-tip-soft: #4f8a1033;
|
||||
--vp-c-danger-1: #f76b64;
|
||||
--vp-c-danger-soft: #f54b4226;
|
||||
--vp-c-warning-1: #ffa726;
|
||||
--vp-c-warning-soft: #ff980026;
|
||||
}
|
||||
|
||||
/* --------------------------- derived bindings ---------------------------- */
|
||||
:root,
|
||||
.dark {
|
||||
--vp-button-brand-bg: var(--nn-accent);
|
||||
--vp-button-brand-text: var(--nn-accent-foreground);
|
||||
--vp-button-brand-border: transparent;
|
||||
--vp-button-brand-hover-bg: #008837e6;
|
||||
--vp-button-brand-hover-text: var(--nn-accent-foreground);
|
||||
--vp-button-brand-hover-border: transparent;
|
||||
--vp-button-brand-active-bg: #008837cc;
|
||||
--vp-button-brand-active-text: var(--nn-accent-foreground);
|
||||
|
||||
--vp-code-bg: var(--nn-code-bg);
|
||||
--vp-code-block-bg: var(--nn-code-bg);
|
||||
--vp-code-copy-code-bg: var(--nn-code-bg);
|
||||
--vp-code-copy-code-hover-bg: var(--nn-hover);
|
||||
|
||||
--vp-shadow-1: var(--nn-shadow-menu);
|
||||
--vp-shadow-2: var(--nn-shadow-menu);
|
||||
--vp-shadow-3: var(--nn-shadow-menu);
|
||||
--vp-shadow-4: var(--nn-shadow-dialog);
|
||||
--vp-shadow-5: var(--nn-shadow-dialog);
|
||||
|
||||
--vp-custom-block-tip-bg: var(--vp-c-tip-soft);
|
||||
--vp-custom-block-warning-bg: var(--vp-c-warning-soft);
|
||||
--vp-custom-block-danger-bg: var(--vp-c-danger-soft);
|
||||
--vp-custom-block-info-bg: var(--vp-c-bg-alt);
|
||||
--vp-custom-block-details-bg: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Base */
|
||||
/* ========================================================================== */
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #00883766;
|
||||
color: var(--nn-selected-fg);
|
||||
}
|
||||
|
||||
.vp-doc h1,
|
||||
.vp-doc h2,
|
||||
.vp-doc h3,
|
||||
.vp-doc h4 {
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vp-doc h1 {
|
||||
font-size: 32px;
|
||||
line-height: 1.25;
|
||||
margin-bottom: var(--nn-space-3);
|
||||
}
|
||||
|
||||
/* The app never underlines its own separators heavily — keep section rules
|
||||
hairline and give sections room to breathe. */
|
||||
.vp-doc h2 {
|
||||
margin-top: 44px;
|
||||
padding-top: var(--nn-space-4);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.vp-doc h3 {
|
||||
margin-top: 28px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.vp-doc p,
|
||||
.vp-doc li {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.vp-doc a {
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.vp-doc a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Navigation & sidebar — modelled on the app's navigation menu */
|
||||
/* ========================================================================== */
|
||||
|
||||
.VPNav {
|
||||
backdrop-filter: saturate(160%) blur(12px);
|
||||
}
|
||||
|
||||
.VPNavBar:not(.home.top) {
|
||||
background-color: color-mix(in srgb, var(--vp-c-bg) 88%, transparent);
|
||||
}
|
||||
|
||||
.VPSidebar {
|
||||
background-color: var(--vp-c-bg-alt) !important;
|
||||
padding-top: var(--nn-space-3);
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-0 > .item > .text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
/* Flat rows with a hover fill — same treatment as list items in the app. */
|
||||
.VPSidebarItem.is-link > .item .link,
|
||||
.VPSidebarItem .item > .link {
|
||||
border-radius: var(--nn-radius-default);
|
||||
}
|
||||
|
||||
/* The hover fill bleeds left of the label so item text keeps the same left edge
|
||||
as the group headers and the nav logo (32px), instead of sitting 10px in. */
|
||||
.VPSidebarItem.level-1 .item,
|
||||
.VPSidebarItem.level-2 .item,
|
||||
.VPSidebarItem.level-3 .item {
|
||||
border-radius: var(--nn-radius-default);
|
||||
margin-left: calc(var(--nn-space-2) * -1);
|
||||
padding-left: var(--nn-space-2);
|
||||
padding-right: var(--nn-space-1);
|
||||
transition: background-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.VPSidebarItem.level-1:not(.is-active) .item:hover,
|
||||
.VPSidebarItem.level-2:not(.is-active) .item:hover,
|
||||
.VPSidebarItem.level-3:not(.is-active) .item:hover {
|
||||
background-color: var(--nn-hover);
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-active > .item {
|
||||
background-color: var(--nn-selected-bg);
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-active > .item > .indicator {
|
||||
background-color: var(--nn-accent);
|
||||
}
|
||||
|
||||
.VPSidebarItem.is-active > .item .link .text {
|
||||
color: var(--nn-selected-fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.VPSidebarItem .text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Buttons */
|
||||
/* ========================================================================== */
|
||||
|
||||
/* One radius for every button on the site. `!important` is needed because the
|
||||
default theme's own button rules carry a scoped [data-v-*] attribute, which
|
||||
outranks a plain class selector. */
|
||||
.VPButton,
|
||||
.VPButton.small,
|
||||
.VPButton.medium,
|
||||
.VPButton.big,
|
||||
.VPNavBarSearchButton,
|
||||
.DocSearch-Button,
|
||||
.VPNavBarHamburger,
|
||||
.VPLocalNav button,
|
||||
.VPBackToTop,
|
||||
.VPSidebarItem .caret,
|
||||
.VPLocalSearchBox button,
|
||||
.VPDocFooter button,
|
||||
[class*="language-"] > button.copy,
|
||||
[class*="language-"] > span.lang + button,
|
||||
.vp-doc button {
|
||||
border-radius: var(--nn-radius-button) !important;
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Home page */
|
||||
/* ========================================================================== */
|
||||
|
||||
.VPHero .image-bg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The default theme nudges the hero image up and left by 32px to optically
|
||||
centre it against the blurred `image-bg` circle behind it. We hide that
|
||||
circle, so the nudge has nothing left to compensate for — it just lifts the
|
||||
logo above the middle of the text column, leaving it centred on the heading
|
||||
and tagline with the action buttons hanging below. Dropping the transform
|
||||
centres it against the whole column: heading, tagline and actions. */
|
||||
@media (min-width: 960px) {
|
||||
.VPHero .image-container {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.VPHero .image-container img {
|
||||
max-width: 208px;
|
||||
max-height: 208px;
|
||||
border-radius: 44px;
|
||||
box-shadow: var(--nn-shadow-dialog);
|
||||
}
|
||||
|
||||
.VPHome .VPFeature {
|
||||
border-radius: var(--nn-radius-large);
|
||||
border-color: var(--vp-c-divider);
|
||||
transition: border-color 120ms ease-out, background-color 120ms ease-out;
|
||||
}
|
||||
|
||||
.VPHome .VPFeature:hover {
|
||||
border-color: var(--nn-accent);
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Custom blocks */
|
||||
/* ========================================================================== */
|
||||
|
||||
.vp-doc .custom-block {
|
||||
border-radius: var(--nn-radius-large);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-left-width: 3px;
|
||||
padding: var(--nn-space-3) var(--nn-space-4);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vp-doc .custom-block .custom-block-title {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.vp-doc .custom-block p {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.info {
|
||||
border-color: var(--vp-c-divider);
|
||||
border-left-color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.tip {
|
||||
border-color: var(--vp-c-tip-soft);
|
||||
border-left-color: var(--vp-c-tip-1);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.warning {
|
||||
border-color: var(--vp-c-warning-soft);
|
||||
border-left-color: var(--vp-c-warning-1);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.danger {
|
||||
border-color: var(--vp-c-danger-soft);
|
||||
border-left-color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block.details {
|
||||
border-left-color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.vp-doc .custom-block img {
|
||||
margin-top: var(--nn-space-2);
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Platform tabs (vitepress-plugin-tabs) */
|
||||
/* ========================================================================== */
|
||||
|
||||
.vp-doc .plugin-tabs {
|
||||
border-radius: var(--nn-radius-large);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vp-doc .plugin-tabs--tab-list {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
padding: var(--nn-space-1) var(--nn-space-1) 0;
|
||||
}
|
||||
|
||||
.vp-doc .plugin-tabs--tab {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
padding: var(--nn-space-1) var(--nn-space-2);
|
||||
border-radius: var(--nn-radius-default) var(--nn-radius-default) 0 0;
|
||||
transition: color 100ms ease-out, background-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.vp-doc .plugin-tabs--tab:hover {
|
||||
background-color: var(--nn-hover);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.vp-doc .plugin-tabs--tab[aria-selected="true"] {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 600;
|
||||
background-color: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.vp-doc .plugin-tabs--tab::after {
|
||||
background-color: transparent;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.vp-doc .plugin-tabs--tab[aria-selected="true"]::after {
|
||||
background-color: var(--nn-accent);
|
||||
}
|
||||
|
||||
.vp-doc .plugin-tabs--content {
|
||||
padding: var(--nn-space-4);
|
||||
}
|
||||
|
||||
.vp-doc .plugin-tabs--content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.vp-doc .plugin-tabs--content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Nested tabs inside a custom block should not double up on chrome. */
|
||||
.vp-doc .custom-block .plugin-tabs {
|
||||
background-color: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Media, code, tables */
|
||||
/* ========================================================================== */
|
||||
|
||||
.vp-doc img {
|
||||
border-radius: var(--nn-radius-large);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* The CSS reset makes every image `display: block`, which breaks the line when a
|
||||
UI glyph is referenced mid-sentence ("press the ⋯ button"). An image sharing
|
||||
its *line* with text is treated as a glyph — inline, sized to the text,
|
||||
unframed. An image on its own line stays a block, even when it sits inside a
|
||||
numbered step whose text is on the line above. */
|
||||
.vp-doc img.inline-glyph {
|
||||
display: inline-block;
|
||||
vertical-align: text-bottom;
|
||||
max-height: 1.5em;
|
||||
width: auto;
|
||||
margin: 0 2px;
|
||||
border: none;
|
||||
border-radius: var(--nn-radius-small);
|
||||
}
|
||||
|
||||
/* ---------------------------- task lists -------------------------------- */
|
||||
|
||||
.vp-doc .contains-task-list {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.vp-doc .task-list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vp-doc .task-list-item-checkbox {
|
||||
appearance: none;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 6px 0 0;
|
||||
border: 1.5px solid var(--vp-c-divider);
|
||||
border-radius: var(--nn-radius-small);
|
||||
background-color: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.vp-doc .task-list-item-checkbox:checked {
|
||||
border-color: var(--nn-accent);
|
||||
background-color: var(--nn-accent);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
|
||||
background-size: 12px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.vp-doc .task-list-item label {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vp-doc img[src$=".svg"] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vp-doc [class*="language-"] {
|
||||
border-radius: var(--nn-radius-large);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.vp-doc code {
|
||||
font-size: 0.875em;
|
||||
border-radius: var(--nn-radius-small);
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.vp-doc :not(pre) > code {
|
||||
color: var(--vp-c-text-1);
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.vp-doc kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--nn-radius-default);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-bottom-width: 2px;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.vp-doc table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-radius: var(--nn-radius-large);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.vp-doc th,
|
||||
.vp-doc td {
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.vp-doc tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.vp-doc tr:nth-child(2n) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.vp-doc tr:hover td {
|
||||
background-color: var(--nn-hover);
|
||||
}
|
||||
|
||||
.vp-doc th {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* Search, footer nav */
|
||||
/* ========================================================================== */
|
||||
|
||||
.VPLocalSearchBox .search-bar {
|
||||
border-radius: var(--nn-radius-default);
|
||||
border: 1.5px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.VPLocalSearchBox .search-bar:focus-within {
|
||||
border-color: var(--nn-accent);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.VPLocalSearchBox .result {
|
||||
border-radius: var(--nn-radius-default);
|
||||
}
|
||||
|
||||
.pager-link {
|
||||
border-radius: var(--nn-radius-large);
|
||||
border-color: var(--vp-c-divider);
|
||||
transition: background-color 100ms ease-out, border-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.pager-link:hover {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-color: var(--nn-accent);
|
||||
}
|
||||
|
||||
.VPDocFooter .edit-link-button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vp-doc h1 {
|
||||
font-size: 27px;
|
||||
}
|
||||
.vp-doc h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
.vp-doc .plugin-tabs--content,
|
||||
.vp-doc .custom-block {
|
||||
padding: var(--nn-space-3);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Documentation versions.
|
||||
*
|
||||
* The **latest** version is served from the site root (`/create-a-note`,
|
||||
* `/organizing-notes/...`) so canonical URLs never move. Older versions are
|
||||
* served from `/v<version>/`.
|
||||
*
|
||||
* Older versions are stored as *differences*, not copies. `contents/_versions/`
|
||||
* holds only the pages whose content actually differs from the current docs;
|
||||
* every other page is shared, and the full `/v<version>/` tree is composed at
|
||||
* build time by `scripts/build-versions.mjs`.
|
||||
*
|
||||
* Cutting a new version: npm run version -- 3.5
|
||||
* Changing a page afterwards: npm run fork -- 3.4 <page> (before editing)
|
||||
*/
|
||||
|
||||
/** The version the docs at the site root describe. */
|
||||
export const LATEST = "3.4";
|
||||
|
||||
/** Older versions, newest first. */
|
||||
export const ARCHIVED = [];
|
||||
|
||||
export const isArchivedPath = (path) =>
|
||||
ARCHIVED.some((v) => path.startsWith(`/v${v}/`));
|
||||
|
||||
export const versionOfPath = (path) =>
|
||||
ARCHIVED.find((v) => path.startsWith(`/v${v}/`)) ?? LATEST;
|
||||
|
||||
/** The version picker shown in the nav bar. */
|
||||
export const versionsNavItem = {
|
||||
text: `v${LATEST}`,
|
||||
items: [
|
||||
{ text: `v${LATEST} (latest)`, link: "/" },
|
||||
...ARCHIVED.map((v) => ({ text: `v${v}`, link: `/v${v}/` }))
|
||||
]
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
# Notesnook Help
|
||||
|
||||
The source of [notesnook.com/help](https://notesnook.com/help), built with [VitePress](https://vitepress.dev).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm run build # production build; fails on dead internal links
|
||||
npm run preview # serve the built site
|
||||
```
|
||||
|
||||
## Where things are
|
||||
|
||||
| | |
|
||||
| ------------------------ | --------------------------------------------------- |
|
||||
| `contents/` | the articles — a file's path here is its public URL |
|
||||
| `contents/public/` | images and fonts, served from `/` |
|
||||
| `.vitepress/config.mts` | site config, nav, head |
|
||||
| `.vitepress/sidebar.mjs` | the sidebar — **add every new article here** |
|
||||
| `.vitepress/theme/` | Notesnook design tokens and self-hosted fonts |
|
||||
|
||||
## UI labels come from the app
|
||||
|
||||
Button and menu labels are written as string keys and resolved at build time from `@notesnook/intl`:
|
||||
|
||||
```md
|
||||
Click on `{{archive}}` to archive the note.
|
||||
```
|
||||
|
||||
Rename that string in the app and this page updates on the next build. An unknown key fails the build. Run `npm run strings` to see which hardcoded labels could become keys (`-- --fix` rewrites them). Don't add strings to `packages/intl` for the docs — if there's no key, write plain text.
|
||||
|
||||
## Writing an article
|
||||
|
||||
1. Create `contents/<section>/<slug>.md` with `title` (short, used in the sidebar) and `description` (one sentence, used as the search snippet) frontmatter.
|
||||
2. Add it to the right group in `.vitepress/sidebar.mjs`.
|
||||
3. Run `npm run build` before opening a PR.
|
||||
|
||||
Steps that differ per platform go in tabs, which stay in sync across the whole site via `key:platform`:
|
||||
|
||||
```md
|
||||
:::tabs key:platform
|
||||
== Desktop/Web
|
||||
|
||||
1. Right click on a note to open the `Note properties` menu.
|
||||
|
||||
== Mobile
|
||||
|
||||
1. Press the three dot button on a note.
|
||||
|
||||
:::
|
||||
```
|
||||
|
||||
Callouts use VitePress containers — `::: info`, `::: tip`, `::: warning`, `::: danger`, `::: details`.
|
||||
|
||||
Renaming or moving a file changes a live URL that the apps and support replies link to. Don't, unless a 301 goes into `contents/public/_redirects` with it. Some pages are linked from inside the app via `packages/intl/src/strings.ts`, and the importer package links to the `importing-notes/*` slugs — grep both before touching a slug.
|
||||
|
||||
## Versioning
|
||||
|
||||
The docs are versioned by Notesnook version. The **latest** version lives at the site root, so canonical URLs never move; older versions are served from `/v<version>/` and reachable from the version picker in the nav bar.
|
||||
|
||||
Older versions are stored as **differences, not copies**. A page is shared by every version until it actually changes; only then does the old text get its own file. `.vitepress/versions.mjs` holds `LATEST` and the list of older versions.
|
||||
|
||||
**When Notesnook ships a new version:**
|
||||
|
||||
```bash
|
||||
npm run version -- <next-version>
|
||||
```
|
||||
|
||||
Nothing is copied — the outgoing version becomes an older version whose pages are all still shared with the root.
|
||||
|
||||
**When you change a page in a way that doesn't apply to the old version**, preserve the old text first, then edit the root copy as usual:
|
||||
|
||||
```bash
|
||||
npm run fork -- <old-version> organizing-notes/archive-notes
|
||||
```
|
||||
|
||||
That writes `contents/_versions/<old-version>/organizing-notes/archive-notes.md` — the only file that version needs. For a page that didn't exist in an older version, add its path to `contents/_versions/<version>/_excluded.txt` instead.
|
||||
|
||||
`npm run versions` (run automatically before dev and build) composes the full `/v<version>/` trees from the shared pages plus those overrides. The composed trees live in `contents/v<version>/` and are gitignored — never edit them.
|
||||
|
||||
Archived pages carry a banner linking to the current version of the same page, are excluded from search and the sitemap, and are `noindex` so they don't compete with the latest docs. Images are shared across versions.
|
||||
|
||||
## Deployment
|
||||
|
||||
`.github/workflows/help.publish.yml` builds and deploys `.vitepress/dist/` to Cloudflare Pages on every push to `master` that touches `docs/help/**`.
|
||||
@@ -1,138 +0,0 @@
|
||||
# Help docs — writing conventions
|
||||
|
||||
Every page on notesnook.com/help follows these. They cover accuracy, plan tags, SEO and linking.
|
||||
|
||||
## 1. Accuracy is non-negotiable
|
||||
|
||||
Never describe UI from memory. Every menu label, settings path, limit, default and plan gate must be traced to source in this monorepo before it is written:
|
||||
|
||||
- user-facing strings — `packages/intl/src/strings.ts`
|
||||
- web/desktop UI — `apps/web/src/`, `apps/desktop/src/`
|
||||
- mobile UI — `apps/mobile/app/`
|
||||
- limits and plan gates — `packages/common/src/utils/is-feature-available.ts`
|
||||
- behaviour, sync, encryption, retention — `packages/core/src/`
|
||||
|
||||
### Quote labels by key, not by hand
|
||||
|
||||
Don't type a label and hope it stays true. Write the **string key** and the build resolves it from the app's own catalogue:
|
||||
|
||||
```md
|
||||
Click on `{{archive}}` renders: Click on `Archive`
|
||||
Open `{{privacyAndSecurity}}` renders: Open `Privacy & security`
|
||||
```
|
||||
|
||||
Keys come from `packages/intl/src/strings.ts` — the same catalogue the apps render from — so when someone renames a string in the app, every page quoting it updates on the next build. An unknown key **fails the build**; it never ships as a placeholder.
|
||||
|
||||
- Plural entries take a count: `{{notebooks:2}}`.
|
||||
- `npm run strings` lists labels that could be keys but aren't; `-- --fix` rewrites the unambiguous ones.
|
||||
- **Never add a string to `packages/intl` for the docs' sake.** If a label has no key — third-party UI, native OS text, a screen that isn't localized — write it as plain text.
|
||||
|
||||
If web and mobile differ, both go in the platform tabs. If you cannot verify something, leave it out and flag it — never guess.
|
||||
|
||||
## 2. Frontmatter
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Archive # short sidebar label, 2–3 words
|
||||
pageTitle: How to archive notes… # optional: SEO <title>, ~60 chars
|
||||
description: One sentence… # required, <160 chars, becomes the search snippet
|
||||
keywords: # optional, real search phrases
|
||||
- archive notes notesnook
|
||||
schema: howto # optional: howto | faq | article (default article)
|
||||
faqs: # required when schema: faq
|
||||
- q: …
|
||||
a: …
|
||||
---
|
||||
```
|
||||
|
||||
`schema: howto` turns the page's numbered steps into HowTo structured data automatically. `schema: faq` emits FAQPage structured data from the `faqs` list — the same Q&As must also appear in the page body.
|
||||
|
||||
## 3. Plan tags
|
||||
|
||||
Any feature that needs a paid plan is tagged inline, on the heading that introduces it:
|
||||
|
||||
```md
|
||||
## Set a note to expire <PlanTag plan="pro" />
|
||||
```
|
||||
|
||||
`plan` is the **lowest** plan that unlocks it: `essential`, `pro` or `believer`. Plans are cumulative — a `pro` tag means Pro and Believer. For platform-limited features add a note:
|
||||
|
||||
```md
|
||||
## Pin a note to your notifications <PlanTag plan="pro" note="Android only" />
|
||||
```
|
||||
|
||||
Verify the tier in `is-feature-available.ts` before tagging. Also state the consequence in prose where it matters ("free plans keep 100 versions per note"), and link to [Plans & limits](/plans-and-limits).
|
||||
|
||||
## 4. Article shape
|
||||
|
||||
```md
|
||||
# Full human title
|
||||
|
||||
One or two sentences: what this is and why someone would want it.
|
||||
|
||||
## Task in imperative form
|
||||
|
||||
:::tabs key:platform
|
||||
== Desktop/Web
|
||||
|
||||
1. …
|
||||
|
||||
== Mobile
|
||||
|
||||
1. …
|
||||
|
||||
:::
|
||||
```
|
||||
|
||||
- One `# H1`, first line of the body.
|
||||
- `##` per task, phrased as an action: "Archive a note", not "Archiving".
|
||||
- Numbered steps, one action each, ideally ≤ 6.
|
||||
- Close with the confirmation state — what the user should now see.
|
||||
- `key:platform` is mandatory on platform tabs so the choice syncs sitewide. Labels: `Desktop/Web`, `Desktop`, `Web`, `Mobile`, `Android`, `iOS`, `Windows`, `macOS`, `Linux`.
|
||||
- If a feature is missing on a platform, say so in that tab rather than omitting it.
|
||||
|
||||
## 5. Callouts
|
||||
|
||||
`::: info` context · `::: tip` shortcut · `::: warning` data loss or something irreversible · `::: danger` unrecoverable · `::: details` folded tangent.
|
||||
|
||||
Anything touching the vault, encryption, recovery or deletion must state plainly that **Notesnook cannot recover data or passwords for you**.
|
||||
|
||||
## 6. SEO
|
||||
|
||||
The help site already ranks #1 for high-intent queries like `import enex`, so each page is a landing page:
|
||||
|
||||
- **Write the H1 as the question a person types.** "How do I import notes from Evernote?" beats "Evernote importing".
|
||||
- **Use real phrasings in `##` headings** — "Can I use it offline?", "Why is my note not syncing?" — they win featured snippets.
|
||||
- **Answer in the first 40 words** after the H1. That paragraph is what Google quotes.
|
||||
- **Never leave alt text empty.** Describe what the reader should look for: ``.
|
||||
- **Every page ends with a `## Related pages` list** of 3–6 links with descriptive anchors (the home page, `/docs` and `/404` are exempt — they are already link lists) — "[backing up your notes](/backup-and-restore-notes-in-notesnook)", never "click here". This is what builds the internal link graph.
|
||||
- **Link the first mention** of any concept that has its own page, in body text, with the concept as the anchor.
|
||||
- **Add `<GetNotesnook />`** to pages people arrive at from search with buying intent — importers, comparisons, "how do I move from X" — placed after the instructions, never before them.
|
||||
- Prefer one page that fully answers a question over three thin pages.
|
||||
|
||||
## 7. Internal linking clusters
|
||||
|
||||
Pages are grouped into clusters, each with a hub that links to every member and members that link back to the hub and sideways to siblings:
|
||||
|
||||
| Cluster | Hub |
|
||||
| ------------------ | ------------------------------------------------------------- |
|
||||
| Importing | [Importing notes](/importing-notes/) |
|
||||
| Editor | [Editor toolbar](/rich-text-editor/rich-text-editor-toolbar) |
|
||||
| Organization | [Notebooks](/organizing-notes/organize-notes-using-notebooks) |
|
||||
| Privacy & security | [How is my data encrypted?](/how-is-my-data-encrypted) |
|
||||
| Sync | [How sync works](/sync/how-sync-works) |
|
||||
| Plans | [Plans & limits](/plans-and-limits) |
|
||||
|
||||
Any page that mentions a paid feature links to the plans hub. Any page that mentions encryption links to the encryption hub.
|
||||
|
||||
## 8. Things that are not allowed
|
||||
|
||||
- Undocumented guesses about UI, limits or plan gates.
|
||||
- Version numbers in body copy ("as of v3.2").
|
||||
- "Simply", "just", "easily", "seamlessly", "powerful".
|
||||
- Telling the reader to contact support before the documented steps.
|
||||
- Image paths that don't exist — leave `<!-- TODO: screenshot — … -->` instead.
|
||||
- Renaming or moving an existing page (its URL is live and linked from the apps). Some pages are linked from inside the app itself via `packages/intl/src/strings.ts` — grep it before touching a slug.
|
||||
- More than one `# H1` on a page, and `##`/`###` headings inside a `:::tabs` block. Headings in tabs are emitted once per tab, so they show up twice in the page outline with duplicate anchors.
|
||||
- Unquoted frontmatter values containing `: ` — the YAML parser fails the build. Quote them.
|
||||
- Alt text that describes nothing: `drawing`, a filename, or an unfilled template. Inline UI glyphs ("press the ⋯ button") are the one case where a short label is correct.
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
title: Oops!
|
||||
description: The page you're looking for could not be found. Here are some helpful links to get you back on track.
|
||||
---
|
||||
|
||||
# Oops!
|
||||
@@ -9,8 +8,6 @@ Unfortunately, we cannot find what you are looking for.
|
||||
|
||||
Here are some other pages you might be interested in:
|
||||
|
||||
1. [Creating your first note](/create-a-note-in-notesnook)
|
||||
2. [Organizing notes with notebooks](/organizing-notes/organize-notes-using-notebooks)
|
||||
3. [How is my data encrypted?](/how-is-my-data-encrypted)
|
||||
|
||||
Still stuck? [Contact us](https://notesnook.com/contact-us).
|
||||
1. [Installing Notesnook](organizing-notes/organize-notes-using-notebooks)
|
||||
2. [Creating your first note](create-a-note-in-notesnook)
|
||||
3. [How is my data encrypted?](how-is-my-data-encrypted)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user