mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 10:09:26 +02:00
Compare commits
409 Commits
fix/284
...
fix/xcode-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8a73a9e1 | ||
|
|
f7c755f2d6 | ||
|
|
10a08d5719 | ||
|
|
6e10ce8d8e | ||
|
|
b5140d9947 | ||
|
|
2288b946ad | ||
|
|
5fd43ae08e | ||
|
|
729c410602 | ||
|
|
bb30336f5c | ||
|
|
0dc89ba945 | ||
|
|
bb51244fa3 | ||
|
|
b5ac41c29b | ||
|
|
aafb6049b2 | ||
|
|
32070327f9 | ||
|
|
771a8bb4e3 | ||
|
|
4f816b47ba | ||
|
|
1071249baa | ||
|
|
d4775ad340 | ||
|
|
0d8c855cd0 | ||
|
|
d63ce5a213 | ||
|
|
a1d6bb91e5 | ||
|
|
a20f6960d7 | ||
|
|
6b2b224f41 | ||
|
|
054a023142 | ||
|
|
441a6396be | ||
|
|
987671e59c | ||
|
|
0fc705ce1e | ||
|
|
b3805534d6 | ||
|
|
77c009e83d | ||
|
|
c9f1cba702 | ||
|
|
637cf914ee | ||
|
|
64877c519b | ||
|
|
229b620473 | ||
|
|
658e915589 | ||
|
|
d4658aa032 | ||
|
|
489c793806 | ||
|
|
eeae993736 | ||
|
|
c8cf4ce52a | ||
|
|
f6c31d19d8 | ||
|
|
a94e8d6852 | ||
|
|
2edbc3bf2e | ||
|
|
e4a7d63e07 | ||
|
|
76fd61af4f | ||
|
|
98386a6d71 | ||
|
|
539a630f77 | ||
|
|
6b65f8031d | ||
|
|
3c85cddfe7 | ||
|
|
e426426468 | ||
|
|
109e46bd06 | ||
|
|
281a973c37 | ||
|
|
2738207c20 | ||
|
|
1aa73c1f23 | ||
|
|
9ec22724e9 | ||
|
|
4a7aa27bd9 | ||
|
|
6051614d96 | ||
|
|
295a461dda | ||
|
|
a4cbe83211 | ||
|
|
afd63db043 | ||
|
|
aadb6e3a01 | ||
|
|
7c3fdab6ee | ||
|
|
14f727d6e6 | ||
|
|
688a646181 | ||
|
|
7a7f0db941 | ||
|
|
96be35c105 | ||
|
|
36bacce3ac | ||
|
|
c89e333c11 | ||
|
|
d521eb3bd8 | ||
|
|
20bf090c7e | ||
|
|
6cc9b8d1e1 | ||
|
|
e931423eba | ||
|
|
743d47e9e9 | ||
|
|
37c96177a7 | ||
|
|
834479d095 | ||
|
|
643515c6c4 | ||
|
|
0b0a4f1b6f | ||
|
|
48d88069c0 | ||
|
|
9d7754f1e1 | ||
|
|
b2ed115011 | ||
|
|
3447e8133c | ||
|
|
4473d8ec67 | ||
|
|
08cf21b05a | ||
|
|
10dccba5b6 | ||
|
|
23c9a98013 | ||
|
|
473fae4a30 | ||
|
|
e9808c08f0 | ||
|
|
4bb46ea149 | ||
|
|
01811372fc | ||
|
|
6d2097259b | ||
|
|
4c3e065928 | ||
|
|
e45b72a8c4 | ||
|
|
aceb6db8b8 | ||
|
|
5a0ca0bed9 | ||
|
|
8dd5779308 | ||
|
|
dc958098ea | ||
|
|
bf536eb183 | ||
|
|
eb3eea031e | ||
|
|
d376e00a1e | ||
|
|
aca36b60b7 | ||
|
|
033666795c | ||
|
|
c0872c98a8 | ||
|
|
f2173a42d9 | ||
|
|
cbddea4518 | ||
|
|
c69304781a | ||
|
|
412631bf67 | ||
|
|
8c2be00920 | ||
|
|
553e2060be | ||
|
|
7603de25c6 | ||
|
|
146216949d | ||
|
|
c4d8abef16 | ||
|
|
baf823264b | ||
|
|
7f0a517ee1 | ||
|
|
da48cf0bd1 | ||
|
|
4c41e13c5d | ||
|
|
82e2953bc8 | ||
|
|
fc244b1196 | ||
|
|
98ecad3450 | ||
|
|
54dcbd9be6 | ||
|
|
d26c99c487 | ||
|
|
0922d9d833 | ||
|
|
0cdeee80fa | ||
|
|
3aa23d3215 | ||
|
|
936b6cb4aa | ||
|
|
e0b9932aba | ||
|
|
f9f974c9f0 | ||
|
|
e3816234ff | ||
|
|
8b36ae6dee | ||
|
|
0bd030dc78 | ||
|
|
63eb42eb9e | ||
|
|
eb75df360f | ||
|
|
89f3a7945b | ||
|
|
7af1387691 | ||
|
|
1d292d740c | ||
|
|
4f884a8411 | ||
|
|
3122e8a379 | ||
|
|
528812d69d | ||
|
|
ab5391fd6e | ||
|
|
770d290af1 | ||
|
|
3db6c2b1fd | ||
|
|
19a11dae26 | ||
|
|
07d7a537e4 | ||
|
|
20f39b82c6 | ||
|
|
9e5ed67eb3 | ||
|
|
6ddd5078ac | ||
|
|
579ae294b6 | ||
|
|
6418476ed6 | ||
|
|
dfc01fa558 | ||
|
|
b4f3593966 | ||
|
|
c0eff80bd4 | ||
|
|
fd55eddf73 | ||
|
|
80a964af67 | ||
|
|
8c3c011ad0 | ||
|
|
632d923c69 | ||
|
|
ce03e01bf2 | ||
|
|
e2aa57909f | ||
|
|
6c9eb57f3d | ||
|
|
e68d60588e | ||
|
|
49aa7860f8 | ||
|
|
9c6cfa0b27 | ||
|
|
4d75a55f33 | ||
|
|
e1e2aec014 | ||
|
|
e82b141aaf | ||
|
|
db53a1e311 | ||
|
|
3216c33fe6 | ||
|
|
d2ca64d7c9 | ||
|
|
91a56058bb | ||
|
|
4e057fa788 | ||
|
|
229edaae55 | ||
|
|
eb265ea89e | ||
|
|
16ee34aceb | ||
|
|
8dd39e6a68 | ||
|
|
d8bc12287c | ||
|
|
5ba3233055 | ||
|
|
ffecb2f9cb | ||
|
|
bfd3b0736d | ||
|
|
26b9dc1149 | ||
|
|
1a6c752c57 | ||
|
|
c7e0225535 | ||
|
|
0cb527f97b | ||
|
|
9c103ea0e3 | ||
|
|
c89a9fad50 | ||
|
|
251e635ccb | ||
|
|
46d5bc83c8 | ||
|
|
9c69514a74 | ||
|
|
a6a74b924e | ||
|
|
8c8f085b16 | ||
|
|
f75ce74c6b | ||
|
|
d7e92346e5 | ||
|
|
17bbaa2323 | ||
|
|
2f47146ae2 | ||
|
|
2dd6f94f41 | ||
|
|
5f88375bec | ||
|
|
3f9bea1d6f | ||
|
|
97d3162d20 | ||
|
|
a12e6ba008 | ||
|
|
dfb1dc7e57 | ||
|
|
c27e8916a1 | ||
|
|
5fe6331c1f | ||
|
|
68d1a1bc34 | ||
|
|
7adc5fbf1e | ||
|
|
46b6888ca3 | ||
|
|
9eec90e333 | ||
|
|
e49291c55e | ||
|
|
70147d4332 | ||
|
|
f8d2649f4d | ||
|
|
44ffe0adf6 | ||
|
|
14cb815e34 | ||
|
|
ca4368a08e | ||
|
|
537c514562 | ||
|
|
d9720ba7b8 | ||
|
|
16e968dc57 | ||
|
|
03fafcbd15 | ||
|
|
b7bd06c9f3 | ||
|
|
766a8f28d4 | ||
|
|
86512b5ce7 | ||
|
|
cdef17eb8d | ||
|
|
4256003e1c | ||
|
|
657f0e428a | ||
|
|
59b003d1f8 | ||
|
|
50d21e8787 | ||
|
|
a820fcde53 | ||
|
|
5ddf1eddf5 | ||
|
|
4237d2eb4a | ||
|
|
f67cd503d6 | ||
|
|
14039d6a1e | ||
|
|
d1fed9072e | ||
|
|
b003a3ffed | ||
|
|
55e10e4755 | ||
|
|
7c1dd0f79a | ||
|
|
229cf195a8 | ||
|
|
38da6ac08b | ||
|
|
597dc587cd | ||
|
|
5c76b92789 | ||
|
|
8418d5fa9c | ||
|
|
cd071f8f58 | ||
|
|
967374d28e | ||
|
|
32ddc3fa40 | ||
|
|
68d07a7862 | ||
|
|
2aa5162fc9 | ||
|
|
adaaabe46c | ||
|
|
cf5565d09d | ||
|
|
f129e0c411 | ||
|
|
bb43d80e05 | ||
|
|
4e4d3866df | ||
|
|
b2088ef782 | ||
|
|
07db14f6bf | ||
|
|
82d37701c5 | ||
|
|
b48ab3c85d | ||
|
|
52d7d90b68 | ||
|
|
bb7ff4f376 | ||
|
|
43b35917ef | ||
|
|
bcfa34ca05 | ||
|
|
8b2f5901fe | ||
|
|
d64d2e44d9 | ||
|
|
4f8c9eb76c | ||
|
|
ba0cfbf44a | ||
|
|
b43374c53f | ||
|
|
f2c08c89b0 | ||
|
|
bd04427e03 | ||
|
|
27ca64694f | ||
|
|
42f229ab03 | ||
|
|
aab6a36067 | ||
|
|
c7e0cd35e7 | ||
|
|
bd3edc535a | ||
|
|
1dff7a121d | ||
|
|
3f3467e3a7 | ||
|
|
39bb9afa0e | ||
|
|
886e757715 | ||
|
|
f1124c4112 | ||
|
|
a1575ab5ea | ||
|
|
d31eaef6d4 | ||
|
|
930e31d109 | ||
|
|
2a6cc90592 | ||
|
|
a13962f8c9 | ||
|
|
5c93645969 | ||
|
|
cad13856af | ||
|
|
a4c8259a02 | ||
|
|
07defdfa78 | ||
|
|
fc93a39164 | ||
|
|
9b588c50bf | ||
|
|
27b00d4ef9 | ||
|
|
3248c82c5c | ||
|
|
691a0a62ea | ||
|
|
2c578c9695 | ||
|
|
ee48990c90 | ||
|
|
436c635889 | ||
|
|
51b226226e | ||
|
|
7deb095aca | ||
|
|
b615dfcd87 | ||
|
|
833368e225 | ||
|
|
9221e21393 | ||
|
|
91e0437554 | ||
|
|
0476d3652a | ||
|
|
4dcf14fce1 | ||
|
|
8f8ac25d20 | ||
|
|
b594d58d31 | ||
|
|
e4c132a8cc | ||
|
|
ba9877b3dd | ||
|
|
20921df3f1 | ||
|
|
43d3036525 | ||
|
|
f725499039 | ||
|
|
cd743a92cb | ||
|
|
a545c9cfac | ||
|
|
ea0550cb05 | ||
|
|
fa03ec5ebe | ||
|
|
79ecbf6752 | ||
|
|
d9d5cb2168 | ||
|
|
69e509da1c | ||
|
|
2d97445f26 | ||
|
|
6ba1fda2f2 | ||
|
|
93efecd6c3 | ||
|
|
70d1119cf0 | ||
|
|
3f11b76811 | ||
|
|
d912c93f81 | ||
|
|
e635bf628a | ||
|
|
8048c90a89 | ||
|
|
8093f44a70 | ||
|
|
d0bfc21b49 | ||
|
|
f67f90cecd | ||
|
|
ff4e2631cb | ||
|
|
9779685102 | ||
|
|
f2925725b8 | ||
|
|
9ca9fe3f99 | ||
|
|
8e5039fa7f | ||
|
|
2f47fe331d | ||
|
|
238abee126 | ||
|
|
a8d0fa3b94 | ||
|
|
27b3cfcd23 | ||
|
|
d3f1690dca | ||
|
|
d4d7eac240 | ||
|
|
e7fb246c25 | ||
|
|
03defb3a48 | ||
|
|
73c44d476b | ||
|
|
61aaaba452 | ||
|
|
9992bf8c32 | ||
|
|
b8b7d37799 | ||
|
|
0aaabfab62 | ||
|
|
14d680c092 | ||
|
|
28f95b0061 | ||
|
|
419dd555f4 | ||
|
|
1312ce7d50 | ||
|
|
e023f11cd9 | ||
|
|
4f805910c4 | ||
|
|
e6a75c1f8a | ||
|
|
6489dda4c8 | ||
|
|
0b1aa3315b | ||
|
|
ebfc554cd7 | ||
|
|
cf3f2fc3ce | ||
|
|
a62fea1a4b | ||
|
|
f83e894758 | ||
|
|
131f6c6d5f | ||
|
|
534cf883be | ||
|
|
8a83a1af23 | ||
|
|
690c30eed3 | ||
|
|
8cc86284ee | ||
|
|
e4f1f2bfa7 | ||
|
|
1f7a64a42d | ||
|
|
2b1cd537e2 | ||
|
|
769d92fa0c | ||
|
|
f6d5d3cebc | ||
|
|
f3ef277a52 | ||
|
|
d30ec4a19d | ||
|
|
206157b68b | ||
|
|
418e4517a5 | ||
|
|
9bd0f0ceb1 | ||
|
|
61a56662eb | ||
|
|
6efd1af0e9 | ||
|
|
8189b2ead8 | ||
|
|
257ac8962c | ||
|
|
faea6635c6 | ||
|
|
537521d568 | ||
|
|
e3ed75461e | ||
|
|
f6b59eeb99 | ||
|
|
b58db58e13 | ||
|
|
195dc18a09 | ||
|
|
187086460d | ||
|
|
84655cec40 | ||
|
|
49b1dd46d1 | ||
|
|
76e14d4434 | ||
|
|
f828ab0314 | ||
|
|
8d3d173762 | ||
|
|
12644fce28 | ||
|
|
358b499d32 | ||
|
|
17864d3325 | ||
|
|
7b6165c8f0 | ||
|
|
c90a2f5179 | ||
|
|
47b2569b29 | ||
|
|
692975d4a0 | ||
|
|
6a843a6457 | ||
|
|
a139bdad1e | ||
|
|
b5dc298d7d | ||
|
|
561477fb1a | ||
|
|
543766d564 | ||
|
|
9789edad4c | ||
|
|
b46e02753a | ||
|
|
5f74fcad0c | ||
|
|
34b5005591 | ||
|
|
2b0a1c6a78 | ||
|
|
fcba5f9c91 | ||
|
|
5909025976 | ||
|
|
c2702efc7f | ||
|
|
c96d13d416 | ||
|
|
3d52a307ca | ||
|
|
32c825463f | ||
|
|
1172a8c67b | ||
|
|
0191e5be00 | ||
|
|
7f367389bb | ||
|
|
e94c757d5b | ||
|
|
29e213ebe2 | ||
|
|
8eb7663f37 |
2
.github/workflows/android.e2e.yml
vendored
2
.github/workflows/android.e2e.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
|
||||
104
.github/workflows/android.preview.build.yml
vendored
Normal file
104
.github/workflows/android.preview.build.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
name: Notesnook Android Preview Build
|
||||
|
||||
# UNTRUSTED stage. Runs on `pull_request`, so fork code is checked out and
|
||||
# compiled with a read-only GITHUB_TOKEN and NO repository secrets. The release
|
||||
# APK is built without any signing secret (the same as before). Firebase
|
||||
# distribution and the PR comment happen in android.preview.publish.yml, which
|
||||
# runs in the trusted `workflow_run` context and never executes fork code.
|
||||
# Because no secrets are exposed here, the build runs automatically for every
|
||||
# PR (including forks) with no authorization gate.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
branches: [master, beta]
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "packages/**"
|
||||
- ".github/workflows/android.preview.build.yml"
|
||||
- ".github/workflows/android.preview.publish.yml"
|
||||
|
||||
concurrency:
|
||||
group: android-preview-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
STAGING_BUILD: true
|
||||
|
||||
steps:
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Use specific Java version for the builds
|
||||
uses: joschi/setup-jdk@v2
|
||||
with:
|
||||
java-version: "17"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: Install node modules
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=mobile
|
||||
|
||||
- name: Make Gradlew Executable
|
||||
run: cd apps/mobile/android && chmod +x ./gradlew
|
||||
|
||||
- name: Get build number
|
||||
id: build-number
|
||||
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for typescript errors
|
||||
run: |
|
||||
npm run tx mobile:build
|
||||
cd apps/mobile
|
||||
npx tsc --noEmit
|
||||
|
||||
- name: Build arm64-v8a apk
|
||||
run: |
|
||||
cd apps/mobile/android
|
||||
./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a -PstagingReleaseBuild=true -PprBuildNumber=${{ steps.build-number.outputs.timestamp }}
|
||||
|
||||
- name: Stage build artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/artifact"
|
||||
cp apps/mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk \
|
||||
"$RUNNER_TEMP/artifact/app-preview.apk"
|
||||
{
|
||||
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
|
||||
echo "HEAD_SHA=${{ github.event.pull_request.head.sha }}"
|
||||
} > "$RUNNER_TEMP/artifact/pr-meta.env"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-preview-build
|
||||
path: ${{ runner.temp }}/artifact
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload sourcemaps
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sourcemaps
|
||||
path: |
|
||||
apps/mobile/android/app/build/generated/sourcemaps/**/*.map
|
||||
135
.github/workflows/android.preview.firebase.yml
vendored
135
.github/workflows/android.preview.firebase.yml
vendored
@@ -1,135 +0,0 @@
|
||||
name: Notesnook Android Preview
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
branches: [master, beta]
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "packages/**"
|
||||
- ".github/workflows/android.preview.firebase.yml"
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
environment: ${{ github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository &&
|
||||
'external' || 'internal' }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo true
|
||||
|
||||
build:
|
||||
needs: authorize
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
STAGING_BUILD: true
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Use specific Java version for the builds
|
||||
uses: joschi/setup-jdk@v2
|
||||
with:
|
||||
java-version: "17"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
# this might remove tools that are actually needed,
|
||||
# if set to "true" but frees about 6 GB
|
||||
tool-cache: false
|
||||
|
||||
# all of these default to true, but feel free to set to
|
||||
# "false" if necessary for your workflow
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: Install node modules
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=mobile
|
||||
|
||||
- name: Make Gradlew Executable
|
||||
run: cd apps/mobile/android && chmod +x ./gradlew
|
||||
|
||||
- name: Get build number
|
||||
id: build-number
|
||||
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for typescript errors
|
||||
run: |
|
||||
npm run tx mobile:build
|
||||
cd apps/mobile
|
||||
npx tsc --noEmit
|
||||
|
||||
- name: Build arm64-v8a apk
|
||||
run: |
|
||||
cd apps/mobile/android
|
||||
./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a -PstagingReleaseBuild=true -PprBuildNumber=${{ steps.build-number.outputs.timestamp }}
|
||||
|
||||
- name: Get app version
|
||||
id: package-version
|
||||
uses: saionaro/extract-package-version@master
|
||||
with:
|
||||
path: apps/mobile
|
||||
|
||||
- name: Publish to firebase CLI
|
||||
id: firebase-output
|
||||
uses: wzieba/Firebase-Distribution-Github-Action@v1
|
||||
with:
|
||||
appId: ${{secrets.FIREBASE_APP_ID}}
|
||||
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
|
||||
groups: testers
|
||||
file: apps/mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
|
||||
releaseNotes: Preview for https://github.com/streetwriters/notesnook/pull/${{github.event.number}}
|
||||
|
||||
- name: Post or update PR comment
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- android-preview-comment -->';
|
||||
const prNumber = context.issue.number;
|
||||
const previewUrl = process.env.preview_url || '';
|
||||
const body = `${marker}\n**Android App 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,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Upload sourcemaps
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sourcemaps
|
||||
path: |
|
||||
apps/mobile/android/app/build/generated/sourcemaps/**/*.map
|
||||
72
.github/workflows/android.preview.publish.yml
vendored
Normal file
72
.github/workflows/android.preview.publish.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Notesnook Android Preview Publish
|
||||
|
||||
# TRUSTED stage. Runs via `workflow_run` after the build workflow finishes, so
|
||||
# it has the base repo's secrets and a write-scoped token. It downloads the APK
|
||||
# the build produced and distributes it + posts the PR comment. It never checks
|
||||
# out or executes fork code.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Notesnook Android Preview Build"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: android-preview-build
|
||||
path: ${{ runner.temp }}/artifact
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Load PR metadata
|
||||
run: cat "$RUNNER_TEMP/artifact/pr-meta.env" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Publish to Firebase
|
||||
id: firebase-output
|
||||
uses: wzieba/Firebase-Distribution-Github-Action@v1
|
||||
with:
|
||||
appId: ${{ secrets.FIREBASE_APP_ID }}
|
||||
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
|
||||
groups: testers
|
||||
file: ${{ runner.temp }}/artifact/app-preview.apk
|
||||
releaseNotes: Preview for https://github.com/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
|
||||
|
||||
- name: Post or update PR comment
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- android-preview-comment -->';
|
||||
const prNumber = Number(process.env.PR_NUMBER);
|
||||
if (!prNumber) return;
|
||||
const previewUrl = process.env.preview_url || '';
|
||||
const body = `${marker}\n**Android App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.HEAD_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,
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
2
.github/workflows/android.publish.yml
vendored
2
.github/workflows/android.publish.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
2
.github/workflows/core.tests.yml
vendored
2
.github/workflows/core.tests.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
needs: authorize
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
|
||||
14
.github/workflows/desktop.preview.yml
vendored
14
.github/workflows/desktop.preview.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
macos-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
linux-x64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -146,6 +146,7 @@ jobs:
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
npm i --cpu x64 sqlite-better-trigram
|
||||
npm i --cpu x64 sqlite3-fts5-html
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Build Electron wrapper
|
||||
@@ -170,7 +171,7 @@ jobs:
|
||||
linux-arm64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -189,6 +190,7 @@ jobs:
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
npm i --cpu arm64 sqlite-better-trigram
|
||||
npm i --cpu arm64 sqlite3-fts5-html
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Build Electron wrapper
|
||||
@@ -208,12 +210,12 @@ jobs:
|
||||
build-windows:
|
||||
name: Build for Windows
|
||||
needs: build
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2022
|
||||
outputs:
|
||||
windows-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
25
.github/workflows/desktop.publish.yml
vendored
25
.github/workflows/desktop.publish.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
target_commitish: ${{ github.ref }}
|
||||
|
||||
- name: Generate flatpak sources
|
||||
if: inputs.publish-github && inputs.build-linux
|
||||
if: inputs.build-linux
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --quiet flatpak flatpak-builder pipx
|
||||
@@ -127,6 +127,13 @@ jobs:
|
||||
files: ./generated-sources.json
|
||||
prerelease: ${{ endsWith(steps.app_metadata.outputs.app_version, '-beta') }}
|
||||
|
||||
- name: Upload generated-sources.json as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: inputs.build-linux
|
||||
with:
|
||||
name: generated-sources
|
||||
path: ./generated-sources.json
|
||||
|
||||
build-macos:
|
||||
name: Build for macOS
|
||||
needs: build
|
||||
@@ -135,7 +142,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -182,7 +189,9 @@ jobs:
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
npm i --cpu arm64 sqlite-better-trigram
|
||||
npm i --cpu arm64 sqlite3-fts5-html
|
||||
npm i --cpu x64 sqlite-better-trigram
|
||||
npm i --cpu x64 sqlite3-fts5-html
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Install provisioning profile
|
||||
@@ -237,7 +246,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -264,6 +273,7 @@ jobs:
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
npm i --cpu x64 sqlite-better-trigram
|
||||
npm i --cpu x64 sqlite3-fts5-html
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Build Electron wrapper
|
||||
@@ -304,7 +314,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -331,6 +341,7 @@ jobs:
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
npm i --cpu arm64 sqlite-better-trigram
|
||||
npm i --cpu arm64 sqlite3-fts5-html
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Build Electron wrapper
|
||||
@@ -363,11 +374,11 @@ jobs:
|
||||
name: Build for Windows
|
||||
needs: build
|
||||
if: inputs.build-windows
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup .NET Core SDK
|
||||
uses: actions/setup-dotnet@v2
|
||||
|
||||
115
.github/workflows/desktop.tests.yml
vendored
115
.github/workflows/desktop.tests.yml
vendored
@@ -18,12 +18,13 @@ on:
|
||||
- ".github/workflows/desktop.tests.yml"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-22.04
|
||||
test-macos-x64:
|
||||
name: Test macOS x64
|
||||
runs-on: macos-15-intel
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -33,44 +34,10 @@ jobs:
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=web
|
||||
|
||||
- name: Generate desktop build
|
||||
run: npm run tx @notesnook/web:build:desktop
|
||||
|
||||
- name: Archive build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: apps/web/build/**/*
|
||||
|
||||
test-macos-x64:
|
||||
name: Test macOS x64
|
||||
needs: build
|
||||
runs-on: macos-15-intel
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Download build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: ./apps/web/build
|
||||
|
||||
- name: Install packages
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=desktop
|
||||
|
||||
- name: Build Electron wrapper
|
||||
run: npm run tx @notesnook/desktop:release
|
||||
|
||||
- name: Build app
|
||||
run: |
|
||||
yarn electron-builder --config=electron-builder.config.js --mac --dir --x64
|
||||
npm i --cpu x64 sqlite-better-trigram
|
||||
npm i --cpu x64 sqlite3-fts5-html
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Run tests x64
|
||||
@@ -87,33 +54,24 @@ jobs:
|
||||
|
||||
test-macos:
|
||||
name: Test macOS
|
||||
needs: build
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Download build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: ./apps/web/build
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=desktop
|
||||
npm run bootstrap -- --scope=web
|
||||
|
||||
- name: Build Electron wrapper
|
||||
run: npm run tx @notesnook/desktop:release
|
||||
|
||||
- name: Build app
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
yarn electron-builder --config=electron-builder.config.js --mac --dir --arm64
|
||||
npm i --cpu arm64 sqlite-better-trigram
|
||||
npm i --cpu arm64 sqlite3-fts5-html
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Run tests arm64
|
||||
@@ -130,39 +88,24 @@ jobs:
|
||||
|
||||
test-linux:
|
||||
name: Test for Linux
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Download build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: ./apps/web/build
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=desktop
|
||||
npm run bootstrap -- --scope=web
|
||||
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
npm i --cpu arm64 sqlite-better-trigram
|
||||
npm i --cpu x64 sqlite-better-trigram
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Build Electron wrapper
|
||||
run: npm run tx @notesnook/desktop:release
|
||||
|
||||
- name: Build app
|
||||
run: |
|
||||
yarn electron-builder --config=electron-builder.config.js --linux --dir --arm64 --x64
|
||||
npm i --cpu x64 sqlite3-fts5-html
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Run tests
|
||||
@@ -179,44 +122,26 @@ jobs:
|
||||
|
||||
test-windows:
|
||||
name: Test for Windows
|
||||
needs: build
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2022
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Download build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build
|
||||
path: ./apps/web/build
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=desktop
|
||||
npm run bootstrap -- --scope=web
|
||||
|
||||
- name: Install sqlite-better-trigram for all arch
|
||||
run: |
|
||||
npm i --cpu arm64 sqlite-better-trigram
|
||||
npm i --cpu x64 sqlite-better-trigram
|
||||
|
||||
npm i --cpu arm64 sqlite3-fts5-html
|
||||
npm i --cpu x64 sqlite3-fts5-html
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Build Electron wrapper
|
||||
run: node scripts/execute.mjs @notesnook/desktop:release
|
||||
|
||||
- name: Build app
|
||||
run: |
|
||||
npx cross-env NOTESNOOK_STAGING=true yarn electron-builder --config=electron-builder.config.js --win --dir --arm64 --x64
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
working-directory: ./apps/desktop
|
||||
|
||||
2
.github/workflows/editor.tests.yml
vendored
2
.github/workflows/editor.tests.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
2
.github/workflows/help.publish.yml
vendored
2
.github/workflows/help.publish.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
152
.github/workflows/ios.preview.build.yml
vendored
Normal file
152
.github/workflows/ios.preview.build.yml
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
name: Notesnook iOS Preview Build
|
||||
|
||||
# UNTRUSTED stage. Runs on `pull_request`, so fork code is checked out and
|
||||
# compiled with a read-only GITHUB_TOKEN and NO repository secrets. It only
|
||||
# produces an *unsigned* archive. Signing, Firebase distribution and PR
|
||||
# comments happen in ios.preview.publish.yml, which runs in the trusted
|
||||
# `workflow_run` context and never executes fork code. Because no secrets are
|
||||
# exposed here, the build runs automatically for every PR (including forks)
|
||||
# with no authorization gate.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
branches: [master, beta]
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "packages/**"
|
||||
- ".github/workflows/ios.preview.build.yml"
|
||||
- ".github/workflows/ios.preview.publish.yml"
|
||||
|
||||
# A fork that spams pushes shouldn't queue up macOS builds.
|
||||
concurrency:
|
||||
group: ios-preview-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout PR code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: "26.1.1"
|
||||
|
||||
- name: Setup iOS Platform
|
||||
run: |
|
||||
# GitHub runs this as `/bin/bash -e`, so `set +e` is required -- without
|
||||
# it the first failure aborts the step before any retry can happen.
|
||||
# Both commands hit "Unable to connect to simulator" (exit 70) when
|
||||
# CoreSimulatorService is wedged, so retry each and bounce it between.
|
||||
set +e
|
||||
DL_DIR="$RUNNER_TEMP/ios-platform"
|
||||
|
||||
retry() {
|
||||
for i in 1 2 3; do
|
||||
"$@" && return 0
|
||||
echo "Attempt $i failed ($*); resetting CoreSimulator..."
|
||||
sudo killall -9 com.apple.CoreSimulator.CoreSimulatorService simdiskimaged 2>/dev/null
|
||||
sleep 20
|
||||
xcrun simctl list runtimes >/dev/null 2>&1
|
||||
done
|
||||
echo "::error::Failed after 3 attempts: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
xcrun simctl list runtimes >/dev/null 2>&1
|
||||
retry xcodebuild -downloadPlatform iOS -exportPath "$DL_DIR"
|
||||
|
||||
# Find the dmg; its name embeds a build number that changes.
|
||||
DMG="$(find "$DL_DIR" -name '*.dmg' | head -n1)"
|
||||
[ -n "$DMG" ] || { echo "::error::No dmg in $DL_DIR"; exit 1; }
|
||||
retry xcodebuild -importPlatform "$DMG"
|
||||
|
||||
- name: Install node modules
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=mobile
|
||||
|
||||
- name: Build packages
|
||||
run: npm run tx @notesnook/mobile:build
|
||||
|
||||
- name: Cache Pods
|
||||
uses: actions/cache@v4
|
||||
id: pods-cache
|
||||
with:
|
||||
path: apps/mobile/ios/Pods
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }}
|
||||
|
||||
- name: Install Pods
|
||||
run: |
|
||||
cd apps/mobile/ios
|
||||
pod install
|
||||
|
||||
- name: Check for typescript errors
|
||||
run: |
|
||||
npm run tx mobile:build
|
||||
cd apps/mobile
|
||||
npx tsc --noEmit
|
||||
|
||||
- name: Get marketing version
|
||||
id: marketing-version
|
||||
run: echo "version=$(grep "IOS_MARKETING_VERSION" apps/mobile/ios/build-configs/ios-build.staging.xcconfig | awk -F'=' '{print $2}' | xargs)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get build number
|
||||
id: build-number
|
||||
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Make staging xcconfig active
|
||||
run: |
|
||||
cd apps/mobile/ios/build-configs
|
||||
./use-ios-build-config.sh staging --marketing-version ${{steps.marketing-version.outputs.version}} --build-number ${{steps.build-number.outputs.timestamp}}
|
||||
|
||||
# Archive unsigned: this job has no certs/profiles (secrets), and the iOS
|
||||
# device SDK forbids ad-hoc signing, so real signing happens in the trusted
|
||||
# publish workflow (ios.preview.publish.yml). Do NOT pass
|
||||
# CODE_SIGN_ENTITLEMENTS="" -- each target must keep its own entitlements so
|
||||
# the archive records them; blanking them dropped the App Group and crashed
|
||||
# MMKV on launch.
|
||||
- name: Archive (unsigned)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
xcodebuild \
|
||||
-workspace apps/mobile/ios/Notesnook.xcworkspace \
|
||||
-scheme Notesnook \
|
||||
-configuration Release \
|
||||
-sdk iphoneos \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Notesnook.xcarchive" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
archive
|
||||
|
||||
- name: Stage build artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/artifact"
|
||||
tar -czf "$RUNNER_TEMP/artifact/Notesnook.xcarchive.tar.gz" \
|
||||
-C "$RUNNER_TEMP" Notesnook.xcarchive
|
||||
# Carry the PR context forward; workflow_run cannot see it reliably for forks.
|
||||
{
|
||||
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
|
||||
echo "HEAD_SHA=${{ github.event.pull_request.head.sha }}"
|
||||
} > "$RUNNER_TEMP/artifact/pr-meta.env"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-preview-build
|
||||
path: ${{ runner.temp }}/artifact
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
168
.github/workflows/ios.preview.firebase.yml
vendored
168
.github/workflows/ios.preview.firebase.yml
vendored
@@ -1,168 +0,0 @@
|
||||
name: Notesnook iOS Preview
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
branches: [master, beta]
|
||||
paths:
|
||||
- "apps/mobile/**"
|
||||
- "packages/**"
|
||||
- ".github/workflows/ios.preview.firebase.yml"
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
environment: ${{ github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository &&
|
||||
'external' || 'internal' }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo true
|
||||
|
||||
build:
|
||||
needs: authorize
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: "26.1.1"
|
||||
|
||||
- name: Install node modules
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
npm run bootstrap -- --scope=mobile
|
||||
|
||||
- name: Build packages
|
||||
run: npm run tx @notesnook/mobile:build
|
||||
|
||||
- name: Cache Pods
|
||||
uses: actions/cache@v3
|
||||
id: pods-cache
|
||||
with:
|
||||
path: apps/mobile/ios/Pods
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }}
|
||||
|
||||
- name: Install Pods
|
||||
run: |
|
||||
cd apps/mobile/ios
|
||||
pod install
|
||||
|
||||
- name: Check for typescript errors
|
||||
run: |
|
||||
npm run tx mobile:build
|
||||
cd apps/mobile
|
||||
npx tsc --noEmit
|
||||
|
||||
- name: Get marketing version
|
||||
id: marketing-version
|
||||
run: echo "version=$(grep "IOS_MARKETING_VERSION" apps/mobile/ios/build-configs/ios-build.staging.xcconfig | awk -F'=' '{print $2}' | xargs)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get build number
|
||||
id: build-number
|
||||
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Make staging xcconfig active
|
||||
run: |
|
||||
cd apps/mobile/ios/build-configs
|
||||
./use-ios-build-config.sh staging --marketing-version ${{steps.marketing-version.outputs.version}} --build-number ${{steps.build-number.outputs.timestamp}}
|
||||
|
||||
- name: Build iOS App
|
||||
uses: ammarahm-ed/ios-build-action@master
|
||||
with:
|
||||
bundle-identifier: org.streetwriters.notesnook
|
||||
scheme: Notesnook
|
||||
configuration: "Release"
|
||||
export-options: apps/mobile/ios/ExportOptionsStaging.plist
|
||||
export-method: "ad-hoc"
|
||||
project-path: apps/mobile/ios/Notesnook.xcodeproj
|
||||
workspace-path: apps/mobile/ios/Notesnook.xcworkspace
|
||||
update-targets: |
|
||||
Notesnook
|
||||
Make Note
|
||||
NotesWidgetExtension
|
||||
disable-targets: Notesnook-tvOS,Notesnook-tvOSTests,NotesnookTests
|
||||
code-signing-identity: Apple Distribution
|
||||
team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
p12-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
certificate-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}
|
||||
app-store-connect-api-key-issuer-id: ${{ secrets.API_KEY_ISSUER_ID }}
|
||||
app-store-connect-api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
|
||||
app-store-connect-api-key-base64: ${{ secrets.APPSTORE_CONNECT_API_KEY_BASE64 }}
|
||||
output-path: Notesnook.ipa
|
||||
mobileprovision-base64: |
|
||||
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_APP }}
|
||||
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_SHARE }}
|
||||
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_WIDGET }}
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-preview-ipa
|
||||
path: Notesnook.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Download IPA artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-preview-ipa
|
||||
path: .
|
||||
|
||||
- name: Publish to firebase CLI
|
||||
id: firebase-output
|
||||
uses: wzieba/Firebase-Distribution-Github-Action@v1
|
||||
with:
|
||||
appId: ${{secrets.FIREBASE_IOS_APP_ID}}
|
||||
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
|
||||
groups: testers
|
||||
file: Notesnook.ipa
|
||||
releaseNotes: Preview for https://github.com/streetwriters/notesnook/pull/${{github.event.number}}
|
||||
|
||||
- name: Post or update PR comment
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- ios-preview-comment -->';
|
||||
const prNumber = context.issue.number;
|
||||
const previewUrl = process.env.preview_url || '';
|
||||
const body = `${marker}\n**iOS App 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,
|
||||
});
|
||||
}
|
||||
222
.github/workflows/ios.preview.publish.yml
vendored
Normal file
222
.github/workflows/ios.preview.publish.yml
vendored
Normal file
@@ -0,0 +1,222 @@
|
||||
name: Notesnook iOS Preview Publish
|
||||
|
||||
# TRUSTED stage. Runs via `workflow_run` after the build workflow finishes, so
|
||||
# it has the base repo's secrets and a write-scoped token. It checks out the
|
||||
# BASE repository (never fork code), downloads the unsigned archive the build
|
||||
# produced, then signs + distributes it and posts the PR comment. Signing is
|
||||
# done directly with `codesign` on the prebuilt archive -- no app build phases
|
||||
# run here, so fork code is never executed in this trusted context.
|
||||
#
|
||||
# Split into two jobs on purpose: signing needs macOS, but the Firebase
|
||||
# distribution action is a Docker container action and only runs on Linux.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Notesnook iOS Preview Build"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
export:
|
||||
# Only publish previews for builds that actually succeeded.
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: macos-26
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
pr_number: ${{ steps.meta.outputs.PR_NUMBER }}
|
||||
head_sha: ${{ steps.meta.outputs.HEAD_SHA }}
|
||||
|
||||
steps:
|
||||
- name: Checkout base repo (trusted)
|
||||
uses: actions/checkout@v5
|
||||
# No `ref` -> checks out the default branch, i.e. trusted base code.
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: "26.1.1"
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-preview-build
|
||||
path: ${{ runner.temp }}/artifact
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Load PR metadata & extract archive
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat "$RUNNER_TEMP/artifact/pr-meta.env" >> "$GITHUB_OUTPUT"
|
||||
tar -xzf "$RUNNER_TEMP/artifact/Notesnook.xcarchive.tar.gz" -C "$RUNNER_TEMP"
|
||||
|
||||
- name: Import signing certificate
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}
|
||||
|
||||
- name: Install provisioning profiles
|
||||
env:
|
||||
PROFILE_APP: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_APP }}
|
||||
PROFILE_SHARE: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_SHARE }}
|
||||
PROFILE_WIDGET: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_WIDGET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||
mkdir -p "$PROFILE_DIR"
|
||||
i=0
|
||||
for p in "$PROFILE_APP" "$PROFILE_SHARE" "$PROFILE_WIDGET"; do
|
||||
echo "$p" | base64 --decode > "$PROFILE_DIR/preview-$i.mobileprovision"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# Sign the unsigned archive by hand. `xcodebuild -exportArchive` re-signs
|
||||
# from the archive's `archived-expanded-entitlements.xcent`, which an
|
||||
# unsigned archive does not contain, so it dropped the App Group and the
|
||||
# app crashed on launch. Instead we sign each bundle directly, taking the
|
||||
# entitlements from each provisioning profile (exactly what Apple
|
||||
# provisioned -- App Group, keychain groups with the right team prefix).
|
||||
# Runs on macOS but executes NO fork build phases, so it stays trusted.
|
||||
- name: Sign IPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PROFILES="$HOME/Library/MobileDevice/Provisioning Profiles"
|
||||
APP="$(find "$RUNNER_TEMP/Notesnook.xcarchive/Products/Applications" -maxdepth 1 -name '*.app' | head -n1)"
|
||||
[ -n "$APP" ] || { echo "::error::No .app in archive"; exit 1; }
|
||||
|
||||
# Single Apple Distribution identity from the imported keychain.
|
||||
IDENTITY="$(security find-identity -v -p codesigning | awk '/Apple Distribution/{print $2; exit}')"
|
||||
[ -n "$IDENTITY" ] || { echo "::error::No Apple Distribution identity found"; exit 1; }
|
||||
echo "Signing identity: $IDENTITY"
|
||||
|
||||
TMP="$RUNNER_TEMP/sign"; mkdir -p "$TMP"
|
||||
|
||||
# Decode a profile's bundle id (application-identifier minus team prefix).
|
||||
profile_bundle_id() {
|
||||
security cms -D -i "$1" -o "$TMP/_p.plist" 2>/dev/null
|
||||
local appid; appid="$(plutil -extract Entitlements.application-identifier raw -o - "$TMP/_p.plist")"
|
||||
echo "${appid#*.}"
|
||||
}
|
||||
# Find the profile matching a bundle id; echoes its path.
|
||||
profile_for() {
|
||||
local want="$1" p
|
||||
for p in "$PROFILES"/*.mobileprovision; do
|
||||
[ -e "$p" ] || continue
|
||||
[ "$(profile_bundle_id "$p")" = "$want" ] && { echo "$p"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# Embed the matching profile and sign a bundle with the profile's entitlements.
|
||||
sign_bundle() {
|
||||
local bundle="$1" bid prof ent
|
||||
bid="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$bundle/Info.plist")"
|
||||
prof="$(profile_for "$bid")" || { echo "::error::No provisioning profile for $bid"; exit 1; }
|
||||
ent="$TMP/ent-$bid.plist"
|
||||
security cms -D -i "$prof" -o "$TMP/_p.plist"
|
||||
plutil -extract Entitlements xml1 -o "$ent" "$TMP/_p.plist"
|
||||
cp "$prof" "$bundle/embedded.mobileprovision"
|
||||
echo "Signing $(basename "$bundle") ($bid)"
|
||||
codesign --force --sign "$IDENTITY" --entitlements "$ent" "$bundle"
|
||||
}
|
||||
|
||||
# Sign inside-out: nested code first, then extensions, then the app.
|
||||
if [ -d "$APP/Frameworks" ]; then
|
||||
find "$APP/Frameworks" -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) -print0 \
|
||||
| while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" "$f"; done
|
||||
fi
|
||||
if [ -d "$APP/PlugIns" ]; then
|
||||
for appex in "$APP/PlugIns"/*.appex; do
|
||||
[ -e "$appex" ] || continue
|
||||
if [ -d "$appex/Frameworks" ]; then
|
||||
find "$appex/Frameworks" -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) -print0 \
|
||||
| while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" "$f"; done
|
||||
fi
|
||||
sign_bundle "$appex"
|
||||
done
|
||||
fi
|
||||
sign_bundle "$APP"
|
||||
|
||||
codesign --verify --deep --strict --verbose=2 "$APP"
|
||||
|
||||
# Package into an IPA.
|
||||
rm -rf "$TMP/Payload"; mkdir -p "$TMP/Payload"
|
||||
cp -R "$APP" "$TMP/Payload/"
|
||||
( cd "$TMP" && zip -qry "$RUNNER_TEMP/Notesnook.ipa" Payload )
|
||||
|
||||
# Fail fast if the App Group didn't make it into the signed app -- its
|
||||
# absence is the launch crash (MMKV nil group path). Never distribute
|
||||
# a build without it.
|
||||
echo "::group::Signed app entitlements"
|
||||
codesign -d --entitlements :- "$APP" 2>/dev/null || true
|
||||
echo "::endgroup::"
|
||||
codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q "group.org.streetwriters.notesnook" \
|
||||
|| { echo "::error::Signed app is missing the App Group entitlement; it would crash on launch."; exit 1; }
|
||||
echo "App Group entitlement present."
|
||||
|
||||
- name: Upload signed IPA
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-preview-ipa
|
||||
path: ${{ runner.temp }}/Notesnook.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Must be Linux: wzieba/Firebase-Distribution-Github-Action is a Docker
|
||||
# container action, which GitHub only supports on Linux runners.
|
||||
distribute:
|
||||
needs: export
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Download signed IPA
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-preview-ipa
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Publish to Firebase
|
||||
id: firebase-output
|
||||
uses: wzieba/Firebase-Distribution-Github-Action@v1
|
||||
with:
|
||||
appId: ${{ secrets.FIREBASE_IOS_APP_ID }}
|
||||
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
|
||||
groups: testers
|
||||
file: ${{ runner.temp }}/Notesnook.ipa
|
||||
releaseNotes: Preview for https://github.com/${{ github.repository }}/pull/${{ needs.export.outputs.pr_number }}
|
||||
|
||||
- name: Post or update PR comment
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
|
||||
PR_NUMBER: ${{ needs.export.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.export.outputs.head_sha }}
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- ios-preview-comment -->';
|
||||
const prNumber = Number(process.env.PR_NUMBER);
|
||||
if (!prNumber) return;
|
||||
const previewUrl = process.env.preview_url || '';
|
||||
const body = `${marker}\n**iOS App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.HEAD_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,
|
||||
});
|
||||
}
|
||||
8
.github/workflows/ios.publish.yml
vendored
8
.github/workflows/ios.publish.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
@@ -19,6 +19,11 @@ jobs:
|
||||
with:
|
||||
xcode-version: "26.1.1"
|
||||
|
||||
- name: Setup iOS Platform
|
||||
run: |
|
||||
xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
|
||||
xcodebuild -importPlatform ~/Downloads/iphonesimulator_26.1_23B86.dmg
|
||||
|
||||
- name: Install node modules
|
||||
run: |
|
||||
npm ci --ignore-scripts --prefer-offline --no-audit
|
||||
@@ -111,6 +116,7 @@ jobs:
|
||||
api-private-key: ${{ secrets.API_KEY }}
|
||||
|
||||
- name: Upload Notesnook.ipa to Github
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Notesnook.zip
|
||||
|
||||
2
.github/workflows/monograph.publish.yml
vendored
2
.github/workflows/monograph.publish.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
2
.github/workflows/theme-builder.publish.yml
vendored
2
.github/workflows/theme-builder.publish.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
2
.github/workflows/themes.publish.yml
vendored
2
.github/workflows/themes.publish.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Collect package metadata
|
||||
id: package_metadata
|
||||
|
||||
2
.github/workflows/vericrypt.publish.yml
vendored
2
.github/workflows/vericrypt.publish.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
2
.github/workflows/web.preview.yml
vendored
2
.github/workflows/web.preview.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/web.publish.yml
vendored
2
.github/workflows/web.publish.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node-with-cache
|
||||
|
||||
4
.github/workflows/web.tests.yml
vendored
4
.github/workflows/web.tests.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
|
||||
@@ -17,155 +17,91 @@ 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 { testCleanup, test } from "./test-override.js";
|
||||
import { writeFile } from "fs/promises";
|
||||
import { Page } from "playwright";
|
||||
import { test, expect } from "@nn/test";
|
||||
import { gt, lt } from "semver";
|
||||
import { describe } from "vitest";
|
||||
import { AppModel } from "../../web/__e2e__/models/app.model.js";
|
||||
|
||||
test("update starts downloading if version is outdated", async ({
|
||||
ctx: { page },
|
||||
expect,
|
||||
onTestFinished
|
||||
}) => {
|
||||
onTestFinished(testCleanup);
|
||||
test.extend({ options: { version: "3.0.0" } })(
|
||||
"update starts downloading if version is outdated",
|
||||
async ({ page }) => {
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
|
||||
await page.waitForSelector("#authForm");
|
||||
expect(
|
||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
||||
).toBe(true);
|
||||
await page
|
||||
.locator(".theme-scope-statusBar")
|
||||
.getByRole("button", { name: /updating/i })
|
||||
.waitFor({ state: "attached" });
|
||||
}
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
||||
.click();
|
||||
|
||||
await skipDialog(page);
|
||||
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
|
||||
await page
|
||||
.locator(".theme-scope-statusBar")
|
||||
.getByRole("button", { name: /updating/i })
|
||||
.waitFor({ state: "attached" });
|
||||
});
|
||||
|
||||
test("update is only shown if version is outdated and auto updates are disabled", async ({
|
||||
ctx,
|
||||
expect,
|
||||
onTestFinished
|
||||
}) => {
|
||||
onTestFinished(testCleanup);
|
||||
|
||||
await ctx.app.close();
|
||||
await writeFile(
|
||||
ctx.configPath,
|
||||
JSON.stringify({
|
||||
test.extend({
|
||||
options: {
|
||||
version: "3.0.0",
|
||||
config: {
|
||||
automaticUpdates: false
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
})(
|
||||
"update is only shown if version is outdated and auto updates are disabled",
|
||||
async ({ page }) => {
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
|
||||
await ctx.relaunch();
|
||||
const app = new AppModel(page);
|
||||
const settings = await app.goToSettings();
|
||||
|
||||
const { page } = ctx;
|
||||
await settings.checkForUpdates();
|
||||
await settings.close();
|
||||
|
||||
await page.waitForSelector("#authForm");
|
||||
|
||||
expect(
|
||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
||||
).toBe(true);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
||||
.click();
|
||||
|
||||
await skipDialog(page);
|
||||
await page
|
||||
.locator(".theme-scope-statusBar")
|
||||
.getByRole("button", { name: /available/i })
|
||||
.waitFor({ state: "attached" });
|
||||
}
|
||||
);
|
||||
|
||||
test.extend({
|
||||
options: {
|
||||
version: "3.0.0-beta.0",
|
||||
config: {
|
||||
automaticUpdates: false,
|
||||
releaseTrack: "beta"
|
||||
}
|
||||
}
|
||||
})("update to stable if it is newer", async ({ page }) => {
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
|
||||
await page
|
||||
const app = new AppModel(page);
|
||||
const settings = await app.goToSettings();
|
||||
|
||||
await settings.checkForUpdates();
|
||||
await settings.close();
|
||||
|
||||
const updateButton = page
|
||||
.locator(".theme-scope-statusBar")
|
||||
.getByRole("button", { name: /available/i })
|
||||
.waitFor({ state: "attached" });
|
||||
.getByRole("button", { name: /available/i });
|
||||
await updateButton.waitFor({ state: "visible" });
|
||||
const content = await updateButton.textContent();
|
||||
const version = content?.split(" ")?.[0] || "";
|
||||
expect(gt(version, "3.0.0-beta.0")).toBe(true);
|
||||
});
|
||||
|
||||
describe("update to stable if it is newer", () => {
|
||||
test.scoped({ options: { version: "3.0.0-beta.0" } });
|
||||
test("test", async ({ ctx, expect, onTestFinished }) => {
|
||||
onTestFinished(testCleanup);
|
||||
|
||||
await ctx.app.close();
|
||||
await writeFile(
|
||||
ctx.configPath,
|
||||
JSON.stringify({
|
||||
automaticUpdates: false,
|
||||
releaseTrack: "beta"
|
||||
})
|
||||
);
|
||||
|
||||
await ctx.relaunch();
|
||||
|
||||
const { page } = ctx;
|
||||
|
||||
await page.waitForSelector("#authForm");
|
||||
|
||||
expect(
|
||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
||||
).toBe(true);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
||||
.click();
|
||||
|
||||
await skipDialog(page);
|
||||
|
||||
test.extend({
|
||||
options: {
|
||||
version: "99.0.0-beta.0",
|
||||
config: {
|
||||
automaticUpdates: false,
|
||||
releaseTrack: "beta"
|
||||
}
|
||||
}
|
||||
})(
|
||||
"update is not available if it latest stable version is older",
|
||||
async ({ page }) => {
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
|
||||
const updateButton = page
|
||||
.locator(".theme-scope-statusBar")
|
||||
.getByRole("button", { name: /available/i });
|
||||
await updateButton.waitFor({ state: "visible" });
|
||||
const content = await updateButton.textContent();
|
||||
const version = content?.split(" ")?.[0] || "";
|
||||
expect(gt(version, "3.0.0-beta.0")).toBe(true);
|
||||
});
|
||||
});
|
||||
const app = new AppModel(page);
|
||||
const settings = await app.goToSettings();
|
||||
|
||||
describe("update is not available if it latest stable version is older", () => {
|
||||
test.scoped({ options: { version: "99.0.0-beta.0" } });
|
||||
test("test", async ({ ctx, expect, onTestFinished }) => {
|
||||
onTestFinished(testCleanup);
|
||||
|
||||
await ctx.app.close();
|
||||
await writeFile(
|
||||
ctx.configPath,
|
||||
JSON.stringify({
|
||||
automaticUpdates: false,
|
||||
releaseTrack: "beta"
|
||||
})
|
||||
);
|
||||
|
||||
await ctx.relaunch();
|
||||
|
||||
const { page } = ctx;
|
||||
|
||||
await page.waitForSelector("#authForm");
|
||||
|
||||
expect(
|
||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
||||
).toBe(true);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
||||
.click();
|
||||
|
||||
await skipDialog(page);
|
||||
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
|
||||
await page
|
||||
.locator(".theme-scope-statusBar")
|
||||
.getByRole("button", { name: /checking for updates/i })
|
||||
.waitFor({ state: "hidden" });
|
||||
await settings.checkForUpdates();
|
||||
await settings.close();
|
||||
|
||||
expect(
|
||||
await page
|
||||
@@ -173,45 +109,24 @@ describe("update is not available if it latest stable version is older", () => {
|
||||
.getByRole("button", { name: /available/i })
|
||||
.isHidden()
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downgrade to stable on switching to stable release track", () => {
|
||||
test.scoped({ options: { version: "99.0.0-beta.0" } });
|
||||
test("test", async ({ ctx, expect, onTestFinished }) => {
|
||||
onTestFinished(testCleanup);
|
||||
|
||||
await ctx.app.close();
|
||||
await writeFile(
|
||||
ctx.configPath,
|
||||
JSON.stringify({
|
||||
automaticUpdates: false,
|
||||
releaseTrack: "stable"
|
||||
})
|
||||
);
|
||||
|
||||
await ctx.relaunch();
|
||||
|
||||
const { page } = ctx;
|
||||
|
||||
await page.waitForSelector("#authForm");
|
||||
|
||||
expect(
|
||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
||||
).toBe(true);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
||||
.click();
|
||||
|
||||
await skipDialog(page);
|
||||
}
|
||||
);
|
||||
|
||||
test.extend({
|
||||
options: {
|
||||
version: "99.0.0-beta.0",
|
||||
config: { automaticUpdates: false, releaseTrack: "stable" }
|
||||
}
|
||||
})(
|
||||
"downgrade to stable on switching to stable release track",
|
||||
async ({ page }) => {
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
|
||||
await page
|
||||
.locator(".theme-scope-statusBar")
|
||||
.getByRole("button", { name: /checking for updates/i })
|
||||
.waitFor({ state: "hidden" });
|
||||
const app = new AppModel(page);
|
||||
const settings = await app.goToSettings();
|
||||
|
||||
await settings.checkForUpdates();
|
||||
await settings.close();
|
||||
|
||||
const updateButton = page
|
||||
.locator(".theme-scope-statusBar")
|
||||
@@ -220,23 +135,5 @@ describe("downgrade to stable on switching to stable release track", () => {
|
||||
const content = await updateButton.textContent();
|
||||
const version = content?.split(" ")?.[0] || "";
|
||||
expect(lt(version, "99.0.0-beta.0")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function skipDialog(page: Page) {
|
||||
try {
|
||||
const dialog = page.locator(".ReactModal__Content");
|
||||
const positiveButton = dialog.locator(
|
||||
"button[data-role='positive-button']"
|
||||
);
|
||||
const negativeButton = dialog.locator(
|
||||
"button[data-role='negative-button']"
|
||||
);
|
||||
if (await positiveButton.isVisible())
|
||||
await positiveButton.click({ timeout: 1000 });
|
||||
else if (await negativeButton.isVisible())
|
||||
await negativeButton.click({ timeout: 1000 });
|
||||
} catch (e) {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
63
apps/desktop/__tests__/electron-test/base-test.ts
Normal file
63
apps/desktop/__tests__/electron-test/base-test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
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 { mergeTests, test } from "@playwright/test";
|
||||
import type { CommonFixtures, CommonWorkerFixtures } from "./common-fixtures";
|
||||
import { commonFixtures } from "./common-fixtures";
|
||||
import { platformTest } from "./platform-fixtures";
|
||||
import { testModeTest } from "./test-mode-fixtures";
|
||||
|
||||
export const base = test;
|
||||
|
||||
export const baseTest = mergeTests(base, platformTest, testModeTest).extend<
|
||||
CommonFixtures,
|
||||
CommonWorkerFixtures
|
||||
>(commonFixtures);
|
||||
|
||||
export function step<
|
||||
This extends NonNullable<unknown>,
|
||||
Args extends any[],
|
||||
Return
|
||||
>(
|
||||
target: (this: This, ...args: Args) => Promise<Return>,
|
||||
context: ClassMethodDecoratorContext<
|
||||
This,
|
||||
(this: This, ...args: Args) => Promise<Return>
|
||||
>
|
||||
) {
|
||||
function replacementMethod(this: This, ...args: Args): Promise<Return> {
|
||||
const name =
|
||||
this.constructor.name +
|
||||
"." +
|
||||
(context.name as string) +
|
||||
"(" +
|
||||
args.map((a) => JSON.stringify(a)).join(",") +
|
||||
")";
|
||||
return test.step(name, async () => {
|
||||
return await target.call(this, ...args);
|
||||
});
|
||||
}
|
||||
return replacementMethod;
|
||||
}
|
||||
|
||||
// declare global {
|
||||
// interface Window {
|
||||
// builtins: Builtins;
|
||||
// }
|
||||
// }
|
||||
347
apps/desktop/__tests__/electron-test/common-fixtures.ts
Normal file
347
apps/desktop/__tests__/electron-test/common-fixtures.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/* eslint-disable header/header */
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Fixtures } from "@playwright/test";
|
||||
import type { ChildProcess } from "child_process";
|
||||
import { execSync, spawn } from "child_process";
|
||||
import net from "net";
|
||||
import fs from "fs";
|
||||
import { stripAnsi } from "./playwright-utils";
|
||||
|
||||
type TestChildParams = {
|
||||
command: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
shell?: boolean;
|
||||
onOutput?: () => void;
|
||||
};
|
||||
|
||||
import childProcess from "child_process";
|
||||
|
||||
type ProcessData = {
|
||||
pid: number; // process ID
|
||||
pgrp: number; // process group ID
|
||||
children: Set<ProcessData>; // direct children of the process
|
||||
};
|
||||
|
||||
function readAllProcessesLinux(): {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
pgrp: number;
|
||||
}[] {
|
||||
const result: { pid: number; ppid: number; pgrp: number }[] = [];
|
||||
for (const dir of fs.readdirSync("/proc")) {
|
||||
const pid = +dir;
|
||||
if (isNaN(pid)) continue;
|
||||
try {
|
||||
const statFile = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
// Format of /proc/*/stat is described https://man7.org/linux/man-pages/man5/proc.5.html
|
||||
const match = statFile.match(
|
||||
/^(?<pid>\d+)\s+\((?<comm>.*)\)\s+(?<state>R|S|D|Z|T|t|W|X|x|K|W|P)\s+(?<ppid>\d+)\s+(?<pgrp>\d+)/
|
||||
);
|
||||
if (match && match.groups) {
|
||||
result.push({
|
||||
pid: +match.groups.pid,
|
||||
ppid: +match.groups.ppid,
|
||||
pgrp: +match.groups.pgrp
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// We don't have access to some /proc/<pid>/stat file.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readAllProcessesMacOS(): {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
pgrp: number;
|
||||
}[] {
|
||||
const result: { pid: number; ppid: number; pgrp: number }[] = [];
|
||||
const processTree = childProcess.spawnSync("ps", ["-eo", "pid,pgid,ppid"]);
|
||||
const lines = processTree.stdout.toString().trim().split("\n");
|
||||
for (const line of lines) {
|
||||
const [pid, pgrp, ppid] = line
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((token) => +token);
|
||||
// On linux, the very first line of `ps` is the header with "PID PGID PPID".
|
||||
if (isNaN(pid) || isNaN(pgrp) || isNaN(ppid)) continue;
|
||||
result.push({ pid, ppid, pgrp });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildProcessTreePosix(pid: number): ProcessData | undefined {
|
||||
// Certain Linux distributions might not have `ps` installed.
|
||||
const allProcesses =
|
||||
process.platform === "darwin"
|
||||
? readAllProcessesMacOS()
|
||||
: readAllProcessesLinux();
|
||||
const pidToProcess = new Map<number, ProcessData>();
|
||||
for (const { pid, pgrp } of allProcesses)
|
||||
pidToProcess.set(pid, { pid, pgrp, children: new Set() });
|
||||
for (const { pid, ppid } of allProcesses) {
|
||||
const parent = pidToProcess.get(ppid);
|
||||
const child = pidToProcess.get(pid);
|
||||
// On POSIX, certain processes might not have parent (e.g. PID=1 and occasionally PID=2)
|
||||
// or we might not have access to it proc info.
|
||||
if (parent && child) parent.children.add(child);
|
||||
}
|
||||
return pidToProcess.get(pid);
|
||||
}
|
||||
|
||||
export class TestChildProcess {
|
||||
params: TestChildParams;
|
||||
process: ChildProcess;
|
||||
output = "";
|
||||
stdout = "";
|
||||
stderr = "";
|
||||
fullOutput = "";
|
||||
onOutput?: (chunk: string | Buffer) => void;
|
||||
exited: Promise<{ exitCode: number | null; signal: string | null }>;
|
||||
exitCode: Promise<number | null>;
|
||||
|
||||
private _outputCallbacks = new Set<() => void>();
|
||||
|
||||
constructor(params: TestChildParams) {
|
||||
this.params = params;
|
||||
// See https://nodejs.org/api/deprecations.html#DEP0190
|
||||
const command = params.shell ? params.command.join(" ") : params.command[0];
|
||||
const args = params.shell ? [] : params.command.slice(1);
|
||||
this.process = spawn(command, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
...params.env
|
||||
},
|
||||
cwd: params.cwd,
|
||||
shell: params.shell,
|
||||
// On non-windows platforms, `detached: true` makes child process a leader of a new
|
||||
// process group, making it possible to kill child process tree with `.kill(-pid)` command.
|
||||
// @see https://nodejs.org/api/child_process.html#child_process_options_detached
|
||||
detached: process.platform !== "win32"
|
||||
});
|
||||
if (process.env.PWTEST_DEBUG)
|
||||
process.stdout.write(`\n\nLaunching ${params.command.join(" ")}\n`);
|
||||
this.onOutput = params.onOutput;
|
||||
|
||||
const appendChunk = (type: "stdout" | "stderr", chunk: string | Buffer) => {
|
||||
this.output += String(chunk);
|
||||
if (type === "stderr") this.stderr += String(chunk);
|
||||
else this.stdout += String(chunk);
|
||||
if (process.env.PWTEST_DEBUG) process.stdout.write(String(chunk));
|
||||
else this.fullOutput += String(chunk);
|
||||
this.onOutput?.(chunk);
|
||||
for (const cb of this._outputCallbacks) cb();
|
||||
this._outputCallbacks.clear();
|
||||
};
|
||||
|
||||
this.process.stderr!.on("data", appendChunk.bind(null, "stderr"));
|
||||
this.process.stdout!.on("data", appendChunk.bind(null, "stdout"));
|
||||
|
||||
const killProcessGroup = this._killProcessTree.bind(this, "SIGKILL");
|
||||
process.on("exit", killProcessGroup);
|
||||
this.exited = new Promise((f) => {
|
||||
this.process.on("exit", (exitCode, signal) => f({ exitCode, signal }));
|
||||
process.off("exit", killProcessGroup);
|
||||
});
|
||||
this.exitCode = this.exited.then((r) => r.exitCode);
|
||||
}
|
||||
|
||||
outputLines(): string[] {
|
||||
const strippedOutput = stripAnsi(this.output);
|
||||
return strippedOutput
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("%%"))
|
||||
.map((line) => line.substring(2).trim());
|
||||
}
|
||||
|
||||
async kill(signal: "SIGINT" | "SIGKILL" = "SIGKILL") {
|
||||
this._killProcessTree(signal);
|
||||
return this.exited;
|
||||
}
|
||||
|
||||
private _killProcessTree(signal: "SIGINT" | "SIGKILL") {
|
||||
if (!this.process.pid || !this.process.kill(0)) return;
|
||||
killProcessGroup(this.process.pid, signal);
|
||||
}
|
||||
|
||||
async cleanExit() {
|
||||
const r = await this.exited;
|
||||
if (r.exitCode)
|
||||
throw new Error(
|
||||
`Process failed with exit code ${r.exitCode}. Output:\n${this.output}`
|
||||
);
|
||||
if (r.signal)
|
||||
throw new Error(
|
||||
`Process received signal: ${r.signal}. Output:\n${this.output}`
|
||||
);
|
||||
}
|
||||
|
||||
async waitForOutput(substring: string, count = 1) {
|
||||
while (countTimes(stripAnsi(this.output), substring) < count)
|
||||
await new Promise<void>((f) => this._outputCallbacks.add(f));
|
||||
}
|
||||
|
||||
clearOutput() {
|
||||
this.output = "";
|
||||
}
|
||||
|
||||
write(chars: string) {
|
||||
this.process.stdin!.write(chars);
|
||||
}
|
||||
}
|
||||
|
||||
export function killProcessGroup(
|
||||
pid: number,
|
||||
signal: "SIGINT" | "SIGKILL" = "SIGKILL"
|
||||
) {
|
||||
// On Windows, we always call `taskkill` no matter signal.
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
execSync(`taskkill /pid ${pid} /T /F /FI "MEMUSAGE gt 0"`, {
|
||||
stdio: "ignore"
|
||||
});
|
||||
} catch (e) {
|
||||
// the process might have already stopped
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In case of POSIX and `SIGINT` signal, send it to the main process group only.
|
||||
if (signal === "SIGINT") {
|
||||
try {
|
||||
process.kill(-pid, "SIGINT");
|
||||
} catch (e) {
|
||||
// the process might have already stopped
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In case of POSIX and `SIGKILL` signal, we should send it to all descendant process groups.
|
||||
const rootProcess = buildProcessTreePosix(pid);
|
||||
if (!rootProcess) return;
|
||||
|
||||
const descendantProcessGroups = (function flatten(
|
||||
processData: ProcessData,
|
||||
result: Set<number> = new Set()
|
||||
) {
|
||||
// Process can nullify its own process group with `setpgid`. Use its PID instead.
|
||||
result.add(processData.pgrp || processData.pid);
|
||||
processData.children.forEach((child) => flatten(child, result));
|
||||
return result;
|
||||
})(rootProcess);
|
||||
for (const pgrp of descendantProcessGroups) {
|
||||
try {
|
||||
process.kill(-pgrp, "SIGKILL");
|
||||
} catch (e) {
|
||||
// the process might have already stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type CommonFixtures = {
|
||||
childProcess: (params: TestChildParams) => TestChildProcess;
|
||||
waitForPort: (port: number) => Promise<void>;
|
||||
findFreePort: () => Promise<number>;
|
||||
};
|
||||
|
||||
export type CommonWorkerFixtures = {
|
||||
daemonProcess: (params: TestChildParams) => TestChildProcess;
|
||||
};
|
||||
|
||||
export const commonFixtures: Fixtures<CommonFixtures, CommonWorkerFixtures> = {
|
||||
childProcess: async ({}, use, testInfo) => {
|
||||
const processes: TestChildProcess[] = [];
|
||||
await use((params) => {
|
||||
const process = new TestChildProcess(params);
|
||||
processes.push(process);
|
||||
return process;
|
||||
});
|
||||
await Promise.all(processes.map(async (child) => child.kill()));
|
||||
if (
|
||||
testInfo.status !== "passed" &&
|
||||
testInfo.status !== "skipped" &&
|
||||
!process.env.PWTEST_DEBUG
|
||||
) {
|
||||
for (const process of processes) {
|
||||
console.log("====== " + process.params.command.join(" "));
|
||||
console.log(process.fullOutput.replace(/\x1Bc/g, ""));
|
||||
console.log("=========================================");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
daemonProcess: [
|
||||
async ({}, use) => {
|
||||
const processes: TestChildProcess[] = [];
|
||||
await use((params) => {
|
||||
const process = new TestChildProcess(params);
|
||||
processes.push(process);
|
||||
return process;
|
||||
});
|
||||
await Promise.all(processes.map((child) => child.kill("SIGINT")));
|
||||
},
|
||||
{ scope: "worker" }
|
||||
],
|
||||
|
||||
waitForPort: async ({}, use) => {
|
||||
const token = { canceled: false };
|
||||
await use(async (port) => {
|
||||
while (!token.canceled) {
|
||||
const promise = new Promise<boolean>((resolve) => {
|
||||
const conn = net
|
||||
.connect(port, "127.0.0.1")
|
||||
.on("error", () => resolve(false))
|
||||
.on("connect", () => {
|
||||
conn.end();
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
if (await promise) return;
|
||||
await new Promise((x) => setTimeout(x, 100));
|
||||
}
|
||||
});
|
||||
token.canceled = true;
|
||||
},
|
||||
|
||||
findFreePort: async ({}, use) => {
|
||||
await use(async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on("error", reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export function countTimes(s: string, sub: string): number {
|
||||
let result = 0;
|
||||
for (let index = 0; index !== -1; ) {
|
||||
index = s.indexOf(sub, index);
|
||||
if (index !== -1) {
|
||||
result++;
|
||||
index += sub.length;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { buildApp } from "./utils";
|
||||
|
||||
export default async function setup() {
|
||||
export default async function globalSetup() {
|
||||
console.log("Building the app...");
|
||||
await buildApp();
|
||||
}
|
||||
152
apps/desktop/__tests__/electron-test/index.ts
Normal file
152
apps/desktop/__tests__/electron-test/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
/* eslint-disable no-empty-pattern */
|
||||
|
||||
import { AppContext, buildAndLaunchApp, TestOptions } from "./utils";
|
||||
import path from "path";
|
||||
import { version } from "../../package.json";
|
||||
import type { ElectronApplication, Page } from "@playwright/test";
|
||||
import type { TraceViewerFixtures } from "./trace-viewer-fixtures";
|
||||
import { traceViewerFixtures } from "./trace-viewer-fixtures";
|
||||
import { PageTestFixtures, PageWorkerFixtures } from "./page-test-api";
|
||||
import fs from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { baseTest } from "./base-test";
|
||||
|
||||
export type ElectronTestFixtures = PageTestFixtures & {
|
||||
electronApp: ElectronApplication;
|
||||
launchElectronApp: (options?: TestOptions) => Promise<ElectronApplication>;
|
||||
createUserDataDir: () => Promise<string>;
|
||||
options: TestOptions;
|
||||
newPage: () => Promise<Page>;
|
||||
};
|
||||
|
||||
export type { Page, Browser } from "@playwright/test";
|
||||
export { expect } from "@playwright/test";
|
||||
export const test = baseTest
|
||||
.extend<TraceViewerFixtures>(traceViewerFixtures)
|
||||
.extend<ElectronTestFixtures, PageWorkerFixtures>({
|
||||
browserVersion: [
|
||||
({}, use) => use(process.env.ELECTRON_CHROMIUM_VERSION!),
|
||||
{ scope: "worker" }
|
||||
],
|
||||
browserMajorVersion: [
|
||||
({}, use) =>
|
||||
use(Number(process.env.ELECTRON_CHROMIUM_VERSION!.split(".")[0])),
|
||||
{ scope: "worker" }
|
||||
],
|
||||
electronMajorVersion: [
|
||||
({}, use) =>
|
||||
use(
|
||||
parseInt(require("electron/package.json").version.split(".")[0], 10)
|
||||
),
|
||||
{ scope: "worker" }
|
||||
],
|
||||
isBidi: [false, { scope: "worker" }],
|
||||
isAndroid: [false, { scope: "worker" }],
|
||||
isElectron: [true, { scope: "worker" }],
|
||||
isHeadlessShell: [false, { scope: "worker" }],
|
||||
isFrozenWebkit: [false, { scope: "worker" }],
|
||||
|
||||
createUserDataDir: async ({}, run) => {
|
||||
const dirs: string[] = [];
|
||||
// We do not put user data dir in testOutputPath,
|
||||
// because we do not want to upload them as test result artifacts.
|
||||
await run(async () => {
|
||||
const dir = await fs.promises.mkdtemp(
|
||||
path.join(tmpdir(), "playwright-test-")
|
||||
);
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
});
|
||||
await removeFolders(dirs);
|
||||
},
|
||||
|
||||
launchElectronApp: async ({ createUserDataDir }, use) => {
|
||||
// This env prevents 'Electron Security Policy' console message.
|
||||
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
|
||||
const apps: AppContext[] = [];
|
||||
await use(async (options?: TestOptions) => {
|
||||
const userDataDir = await createUserDataDir();
|
||||
const ctx = await buildAndLaunchApp(
|
||||
userDataDir,
|
||||
options || {
|
||||
version,
|
||||
config: {}
|
||||
}
|
||||
);
|
||||
apps.push(ctx);
|
||||
return ctx.app;
|
||||
});
|
||||
for (const ctx of apps) {
|
||||
await ctx.app.close();
|
||||
}
|
||||
await removeFolders(apps.map((ctx) => ctx.outputDir));
|
||||
},
|
||||
|
||||
electronApp: async ({ launchElectronApp, options }, use) => {
|
||||
await use(await launchElectronApp(options));
|
||||
},
|
||||
|
||||
page: async ({ electronApp, viewport }, run) => {
|
||||
const page = await electronApp.firstWindow();
|
||||
if (viewport) {
|
||||
await page.setViewportSize(viewport);
|
||||
await electronApp.evaluate((p, viewport) => {
|
||||
const mainWindow = p.BrowserWindow.getAllWindows()[0];
|
||||
mainWindow.setSize(viewport.width, viewport.height);
|
||||
}, viewport);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
page.context().app = electronApp;
|
||||
await run(page);
|
||||
},
|
||||
|
||||
context: async ({ electronApp }, run) => {
|
||||
await run(electronApp.context());
|
||||
},
|
||||
|
||||
newPage: async ({ launchElectronApp, options }, use) => {
|
||||
await use(async () => {
|
||||
const app = await launchElectronApp(options);
|
||||
return app.firstWindow();
|
||||
});
|
||||
},
|
||||
|
||||
options: async ({}, use) => {
|
||||
await use({
|
||||
version,
|
||||
config: {}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function removeFolders(folders: string[]) {
|
||||
await Promise.all(
|
||||
folders.map((folder) =>
|
||||
fs.promises.rm(folder, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 5000
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
58
apps/desktop/__tests__/electron-test/page-test-api.ts
Normal file
58
apps/desktop/__tests__/electron-test/page-test-api.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable header/header */
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Browser, Page, ViewportSize } from "playwright-core";
|
||||
import type {
|
||||
PageScreenshotOptions,
|
||||
ScreenshotMode,
|
||||
VideoMode
|
||||
} from "@playwright/test";
|
||||
export { expect } from "@playwright/test";
|
||||
|
||||
// Page test does not guarantee an isolated context, just a new page (because Android).
|
||||
export type PageTestFixtures = {
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export type PageWorkerFixtures = {
|
||||
headless: boolean;
|
||||
channel: string | undefined;
|
||||
screenshot:
|
||||
| ScreenshotMode
|
||||
| ({ mode: ScreenshotMode } & Pick<
|
||||
PageScreenshotOptions,
|
||||
"fullPage" | "omitBackground"
|
||||
>);
|
||||
trace:
|
||||
| "off"
|
||||
| "on"
|
||||
| "retain-on-failure"
|
||||
| "on-first-retry"
|
||||
| "retain-on-first-failure"
|
||||
| "on-all-retries"
|
||||
| /** deprecated */ "retry-with-trace";
|
||||
video: VideoMode | { mode: VideoMode; size: ViewportSize };
|
||||
browserName: "chromium" | "firefox" | "webkit";
|
||||
browserVersion: string;
|
||||
browserMajorVersion: number;
|
||||
electronMajorVersion: number;
|
||||
isBidi: boolean;
|
||||
isAndroid: boolean;
|
||||
isElectron: boolean;
|
||||
isHeadlessShell: boolean;
|
||||
isFrozenWebkit: boolean;
|
||||
};
|
||||
47
apps/desktop/__tests__/electron-test/platform-fixtures.ts
Normal file
47
apps/desktop/__tests__/electron-test/platform-fixtures.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable header/header */
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test } from "@playwright/test";
|
||||
import os from "os";
|
||||
|
||||
export type PlatformWorkerFixtures = {
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
isWindows: boolean;
|
||||
isMac: boolean;
|
||||
isLinux: boolean;
|
||||
macVersion: number; // major only, 11 or later, zero if not mac
|
||||
};
|
||||
|
||||
function platform(): "win32" | "darwin" | "linux" {
|
||||
if (process.env.PLAYWRIGHT_SERVICE_OS === "linux") return "linux";
|
||||
if (process.env.PLAYWRIGHT_SERVICE_OS === "windows") return "win32";
|
||||
if (process.env.PLAYWRIGHT_SERVICE_OS === "macos") return "darwin";
|
||||
return process.platform as "win32" | "darwin" | "linux";
|
||||
}
|
||||
|
||||
function macVersion() {
|
||||
if (process.platform !== "darwin") return 0;
|
||||
return +os.release().split(".")[0] - 9;
|
||||
}
|
||||
|
||||
export const platformTest = test.extend<{}, PlatformWorkerFixtures>({
|
||||
platform: [platform(), { scope: "worker" }],
|
||||
isWindows: [platform() === "win32", { scope: "worker" }],
|
||||
isMac: [platform() === "darwin", { scope: "worker" }],
|
||||
isLinux: [platform() === "linux", { scope: "worker" }],
|
||||
macVersion: [macVersion(), { scope: "worker" }]
|
||||
});
|
||||
@@ -17,9 +17,10 @@ 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 { SQLite } from "@notesnook/desktop";
|
||||
import { expose } from "comlink";
|
||||
|
||||
export type SQLiteWorker = typeof SQLite.prototype;
|
||||
const db = new SQLite();
|
||||
expose(db);
|
||||
const ansiRegex = new RegExp(
|
||||
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
|
||||
"g"
|
||||
);
|
||||
export function stripAnsi(str: string): string {
|
||||
return str.replace(ansiRegex, "");
|
||||
}
|
||||
75
apps/desktop/__tests__/electron-test/test-mode-fixtures.ts
Normal file
75
apps/desktop/__tests__/electron-test/test-mode-fixtures.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
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 { test } from "@playwright/test";
|
||||
import type { TestModeName } from "./test-mode";
|
||||
import { DefaultTestMode, DriverTestMode } from "./test-mode";
|
||||
|
||||
export type TestModeWorkerOptions = {
|
||||
mode: TestModeName;
|
||||
};
|
||||
|
||||
export type TestModeTestFixtures = {
|
||||
toImpl: (rpcObject?: any) => any;
|
||||
};
|
||||
|
||||
export type TestModeWorkerFixtures = {
|
||||
toImplInWorkerScope: (rpcObject?: any) => any;
|
||||
playwright: typeof import("@playwright/test");
|
||||
};
|
||||
|
||||
export const testModeTest = test.extend<
|
||||
TestModeTestFixtures,
|
||||
TestModeWorkerOptions & TestModeWorkerFixtures
|
||||
>({
|
||||
mode: ["default", { scope: "worker", option: true }],
|
||||
playwright: [
|
||||
async ({ mode }, run) => {
|
||||
const testMode = {
|
||||
default: new DefaultTestMode(),
|
||||
service: new DefaultTestMode(),
|
||||
service2: new DefaultTestMode(),
|
||||
"service-grid": new DefaultTestMode(),
|
||||
wsl: new DefaultTestMode(),
|
||||
driver: new DriverTestMode()
|
||||
}[mode];
|
||||
const playwright = await testMode.setup();
|
||||
await run(playwright);
|
||||
await testMode.teardown();
|
||||
},
|
||||
{ scope: "worker" }
|
||||
],
|
||||
|
||||
toImplInWorkerScope: [
|
||||
async ({ playwright }, use) => {
|
||||
await use((playwright as any)._connection.toImpl);
|
||||
},
|
||||
{ scope: "worker" }
|
||||
],
|
||||
|
||||
toImpl: async (
|
||||
{ toImplInWorkerScope: toImplWorker, mode },
|
||||
use,
|
||||
testInfo
|
||||
) => {
|
||||
if (mode !== "default" || process.env.PW_TEST_REUSE_CONTEXT)
|
||||
testInfo.skip();
|
||||
await use(toImplWorker);
|
||||
}
|
||||
});
|
||||
52
apps/desktop/__tests__/electron-test/test-mode.ts
Normal file
52
apps/desktop/__tests__/electron-test/test-mode.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable header/header */
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
/// @ts-ignore
|
||||
import { oop, client } from "playwright-core/lib/coreBundle";
|
||||
|
||||
export type TestModeName = "default" | "driver";
|
||||
|
||||
const { start } = oop;
|
||||
|
||||
interface TestMode {
|
||||
setup(): Promise<client.Playwright>;
|
||||
teardown(): Promise<void>;
|
||||
}
|
||||
|
||||
export class DriverTestMode implements TestMode {
|
||||
private _impl: { playwright: client.Playwright; stop: () => Promise<void> };
|
||||
|
||||
async setup() {
|
||||
this._impl = await start({
|
||||
NODE_OPTIONS: undefined // Hide driver process while debugging.
|
||||
});
|
||||
return this._impl.playwright;
|
||||
}
|
||||
|
||||
async teardown() {
|
||||
await this._impl.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultTestMode implements TestMode {
|
||||
async setup() {
|
||||
return require("playwright-core");
|
||||
}
|
||||
|
||||
async teardown() {}
|
||||
}
|
||||
243
apps/desktop/__tests__/electron-test/trace-viewer-fixtures.ts
Normal file
243
apps/desktop/__tests__/electron-test/trace-viewer-fixtures.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/* eslint-disable header/header */
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Fixtures,
|
||||
FrameLocator,
|
||||
Locator,
|
||||
Page,
|
||||
Browser,
|
||||
BrowserContext
|
||||
} from "@playwright/test";
|
||||
import { step } from "./base-test";
|
||||
import path from "path";
|
||||
import { CommonFixtures, TestChildProcess } from "./common-fixtures";
|
||||
|
||||
type BaseTestFixtures = CommonFixtures & {
|
||||
context: BrowserContext;
|
||||
};
|
||||
|
||||
type BaseWorkerFixtures = {
|
||||
headless: boolean;
|
||||
browser: Browser;
|
||||
browserName: "chromium" | "firefox" | "webkit";
|
||||
playwright: typeof import("@playwright/test");
|
||||
};
|
||||
|
||||
export type TraceViewerFixtures = {
|
||||
showTraceViewer: (
|
||||
trace: string | undefined,
|
||||
options?: { host?: string; port?: number; stdin?: boolean }
|
||||
) => Promise<TraceViewerPage>;
|
||||
runAndTrace: (
|
||||
body: () => Promise<void>,
|
||||
optsOverrides?: Parameters<BrowserContext["tracing"]["start"]>[0]
|
||||
) => Promise<TraceViewerPage>;
|
||||
};
|
||||
|
||||
class TraceViewerPage {
|
||||
actionTitles: Locator;
|
||||
actionsTree: Locator;
|
||||
callLines: Locator;
|
||||
consoleLines: Locator;
|
||||
logLines: Locator;
|
||||
errorMessages: Locator;
|
||||
consoleLineMessages: Locator;
|
||||
consoleStacks: Locator;
|
||||
networkRequests: Locator;
|
||||
metadataTab: Locator;
|
||||
snapshotContainer: Locator;
|
||||
sourceCodeTab: Locator;
|
||||
networkTab: Locator;
|
||||
|
||||
settingsDialog: Locator;
|
||||
themeSetting: Locator;
|
||||
displayCanvasContentSetting: Locator;
|
||||
|
||||
constructor(public page: Page, public process: TestChildProcess) {
|
||||
this.actionTitles = page.locator(".action-title");
|
||||
this.actionsTree = page.getByTestId("actions-tree");
|
||||
this.callLines = page.locator(".call-tab .call-line");
|
||||
this.logLines = page
|
||||
.getByRole("list", { name: "Log entries" })
|
||||
.getByRole("listitem");
|
||||
this.consoleLines = page
|
||||
.getByRole("tabpanel", { name: "Console" })
|
||||
.getByRole("listitem");
|
||||
this.consoleLineMessages = page.locator(".console-line-message");
|
||||
this.errorMessages = page.locator(".error-message");
|
||||
this.consoleStacks = page.locator(".console-stack");
|
||||
this.networkRequests = page
|
||||
.getByRole("list", { name: "Network requests" })
|
||||
.getByRole("listitem");
|
||||
this.snapshotContainer = page.locator(
|
||||
".snapshot-container iframe.snapshot-visible[name=snapshot]"
|
||||
);
|
||||
this.metadataTab = page.getByRole("tabpanel", { name: "Metadata" });
|
||||
this.sourceCodeTab = page.getByRole("tabpanel", { name: "Source" });
|
||||
this.networkTab = page.getByRole("tabpanel", { name: "Network" });
|
||||
|
||||
this.settingsDialog = page.getByTestId("settings-toolbar-dialog");
|
||||
this.themeSetting = this.settingsDialog.getByRole("combobox", {
|
||||
name: "Theme"
|
||||
});
|
||||
this.displayCanvasContentSetting = page
|
||||
.locator(".setting")
|
||||
.getByText("Display canvas content");
|
||||
}
|
||||
|
||||
@step
|
||||
async showAllActions() {
|
||||
await this.page.getByRole("button", { name: "Filter actions" }).click();
|
||||
await this.page.locator(".setting").getByText("Network routes").click();
|
||||
await this.page.locator(".setting").getByText("Getters").click();
|
||||
await this.page.locator(".setting").getByText("Configuration").click();
|
||||
await this.page.getByRole("button", { name: "Filter actions" }).click();
|
||||
}
|
||||
|
||||
stackFrames(options: { selected?: boolean } = {}) {
|
||||
const entry = this.page
|
||||
.getByRole("list", { name: "Stack trace" })
|
||||
.getByRole("listitem");
|
||||
if (options.selected) return entry.locator(":scope.selected");
|
||||
return entry;
|
||||
}
|
||||
|
||||
actionIconsText(action: string) {
|
||||
const entry = this.actionsTree.getByRole("treeitem", { name: action });
|
||||
return entry.locator(".action-icon-value").filter({ visible: true });
|
||||
}
|
||||
|
||||
actionIcons(action: string) {
|
||||
return this.actionsTree
|
||||
.getByRole("treeitem", { name: action })
|
||||
.locator(".action-icons")
|
||||
.filter({ visible: true });
|
||||
}
|
||||
|
||||
@step
|
||||
async expandAction(title: string) {
|
||||
await this.actionsTree
|
||||
.getByRole("treeitem", { name: title })
|
||||
.locator(".codicon-chevron-right")
|
||||
.click();
|
||||
}
|
||||
|
||||
@step
|
||||
async selectAction(title: string, ordinal: number = 0) {
|
||||
await this.actionsTree.getByTitle(title).nth(ordinal).click();
|
||||
}
|
||||
|
||||
@step
|
||||
async hoverAction(title: string, ordinal: number = 0) {
|
||||
await this.actionsTree
|
||||
.getByRole("treeitem", { name: title })
|
||||
.nth(ordinal)
|
||||
.hover();
|
||||
}
|
||||
|
||||
@step
|
||||
async selectSnapshot(name: string) {
|
||||
await this.page.getByRole("tab", { name }).click();
|
||||
}
|
||||
|
||||
async showErrorsTab() {
|
||||
await this.page.getByRole("tab", { name: "Errors" }).click();
|
||||
}
|
||||
|
||||
async showConsoleTab() {
|
||||
await this.page.getByRole("tab", { name: "Console" }).click();
|
||||
}
|
||||
|
||||
async showSourceTab() {
|
||||
await this.page.getByRole("tab", { name: "Source" }).click();
|
||||
}
|
||||
|
||||
async showNetworkTab() {
|
||||
await this.page.getByRole("tab", { name: "Network" }).click();
|
||||
}
|
||||
|
||||
async showMetadataTab() {
|
||||
await this.page.getByRole("tab", { name: "Metadata" }).click();
|
||||
}
|
||||
|
||||
async showSettings() {
|
||||
await this.page.getByRole("button", { name: "Settings" }).click();
|
||||
}
|
||||
|
||||
@step
|
||||
async snapshotFrame(
|
||||
actionName: string,
|
||||
ordinal: number = 0,
|
||||
hasSubframe: boolean = false
|
||||
): Promise<FrameLocator> {
|
||||
await this.selectAction(actionName, ordinal);
|
||||
while (this.page.frames().length < (hasSubframe ? 4 : 3))
|
||||
await this.page.waitForEvent("frameattached");
|
||||
return this.page.frameLocator("iframe.snapshot-visible[name=snapshot]");
|
||||
}
|
||||
}
|
||||
|
||||
export const traceViewerFixtures: Fixtures<
|
||||
TraceViewerFixtures,
|
||||
{},
|
||||
BaseTestFixtures,
|
||||
BaseWorkerFixtures
|
||||
> = {
|
||||
showTraceViewer: async ({ playwright, childProcess, browserName }, use) => {
|
||||
const browsers: Browser[] = [];
|
||||
await use(async (trace: string | undefined, { host, port, stdin } = {}) => {
|
||||
const command = [
|
||||
"node",
|
||||
path.join(__dirname, "../../node_modules/playwright-core/cli.js"),
|
||||
"show-trace",
|
||||
"--port",
|
||||
"" + (port ?? "0")
|
||||
];
|
||||
if (host) command.push("--host", host);
|
||||
if (stdin) command.push("--stdin");
|
||||
if (trace) command.push(trace);
|
||||
const cp = childProcess({ command });
|
||||
await cp.waitForOutput("Listening on");
|
||||
const browser = await playwright.chromium.launch({
|
||||
...(browserName === "chromium" ? {} : { channel: "chromium" }),
|
||||
executablePath: process.env.CRPATH // without this, setting FFPATH makes us launch Firefox with Chromium args
|
||||
});
|
||||
browsers.push(browser);
|
||||
const page = await browser.newPage();
|
||||
const url = cp.output.match(/Listening on (http:\/\/[^\s]+)/)![1];
|
||||
await page.goto(url);
|
||||
return new TraceViewerPage(page, cp);
|
||||
});
|
||||
for (const browser of browsers) await browser.close();
|
||||
},
|
||||
|
||||
runAndTrace: async ({ context, showTraceViewer }, use, testInfo) => {
|
||||
await use(async (body: () => Promise<void>, optsOverrides = {}) => {
|
||||
const traceFile = testInfo.outputPath("trace.zip");
|
||||
await context.tracing.start({
|
||||
snapshots: true,
|
||||
screenshots: true,
|
||||
sources: true,
|
||||
...optsOverrides
|
||||
});
|
||||
await body();
|
||||
await context.tracing.stop({ path: traceFile });
|
||||
return showTraceViewer(traceFile);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -19,21 +19,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { cp } from "fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path, { join, resolve } from "path";
|
||||
import { _electron as electron } from "playwright";
|
||||
import {
|
||||
_electron as electron,
|
||||
ElectronApplication,
|
||||
Page
|
||||
} from "@playwright/test";
|
||||
import { existsSync } from "fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const IS_DEBUG = process.env.NN_DEBUG === "true" || process.env.CI === "true";
|
||||
const productName = `NotesnookTestHarness`;
|
||||
const SOURCE_DIR = resolve("output", productName);
|
||||
const root = path.resolve(__dirname, "..", "..");
|
||||
const SOURCE_DIR = resolve(root, "output", productName);
|
||||
|
||||
export interface AppContext {
|
||||
app: import("playwright").ElectronApplication;
|
||||
page: import("playwright").Page;
|
||||
configPath: string;
|
||||
app: ElectronApplication;
|
||||
userDataDir: string;
|
||||
outputDir: string;
|
||||
relaunch: () => Promise<void>;
|
||||
@@ -41,6 +42,8 @@ export interface AppContext {
|
||||
|
||||
export interface TestOptions {
|
||||
version: string;
|
||||
args?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Fixtures {
|
||||
@@ -49,35 +52,43 @@ export interface Fixtures {
|
||||
}
|
||||
|
||||
export async function buildAndLaunchApp(
|
||||
userDataDir: string,
|
||||
options?: TestOptions
|
||||
): Promise<AppContext> {
|
||||
await buildApp(options?.version);
|
||||
|
||||
const productName = `notesnooktest${makeid(10)}`;
|
||||
const outputDir = path.join("test-artifacts", `${productName}-output`);
|
||||
const outputDir = path.join(root, "test-artifacts", `${productName}-output`);
|
||||
const executablePath = await copyBuild({
|
||||
...options,
|
||||
outputDir
|
||||
});
|
||||
const { app, page, configPath, userDataDir } = await launchApp(
|
||||
|
||||
const configPath = path.join(userDataDir, "UserData", "config.json");
|
||||
if (options?.config) {
|
||||
await mkdir(path.dirname(configPath), { recursive: true });
|
||||
await writeFile(configPath, JSON.stringify(options.config));
|
||||
}
|
||||
|
||||
const { app } = await launchApp(
|
||||
executablePath,
|
||||
productName,
|
||||
options?.version
|
||||
userDataDir,
|
||||
options?.version,
|
||||
options?.args
|
||||
);
|
||||
const ctx: AppContext = {
|
||||
app,
|
||||
page,
|
||||
configPath,
|
||||
userDataDir,
|
||||
outputDir,
|
||||
relaunch: async () => {
|
||||
const { app, page, configPath, userDataDir } = await launchApp(
|
||||
const { app } = await launchApp(
|
||||
executablePath,
|
||||
productName,
|
||||
options?.version
|
||||
userDataDir,
|
||||
options?.version,
|
||||
options?.args
|
||||
);
|
||||
ctx.app = app;
|
||||
ctx.page = page;
|
||||
ctx.userDataDir = userDataDir;
|
||||
ctx.configPath = configPath;
|
||||
}
|
||||
};
|
||||
return ctx;
|
||||
@@ -85,19 +96,15 @@ export async function buildAndLaunchApp(
|
||||
|
||||
async function launchApp(
|
||||
executablePath: string,
|
||||
packageName: string,
|
||||
version?: string
|
||||
userDataDir: string,
|
||||
version?: string,
|
||||
args: string[] = []
|
||||
) {
|
||||
const userDataDir = resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"test-artifacts",
|
||||
"user_data_dirs",
|
||||
packageName
|
||||
);
|
||||
const app = await electron.launch({
|
||||
executablePath,
|
||||
args: IS_DEBUG ? [] : ["--hidden"],
|
||||
args: IS_DEBUG ? [...args] : ["--hidden", ...args],
|
||||
baseURL: "https://app.notesnook.com",
|
||||
acceptDownloads: true,
|
||||
env: {
|
||||
...(process.platform === "linux"
|
||||
? {
|
||||
@@ -114,20 +121,28 @@ async function launchApp(
|
||||
}
|
||||
});
|
||||
|
||||
const page = await app.firstWindow();
|
||||
|
||||
const configPath = path.join(userDataDir, "UserData", "config.json");
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
configPath,
|
||||
userDataDir
|
||||
};
|
||||
}
|
||||
|
||||
export function getAppFromPage(page: Page) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
return page.context().app as ElectronApplication;
|
||||
}
|
||||
|
||||
let MAX_RETRIES = 3;
|
||||
export async function buildApp(version?: string) {
|
||||
if (!existsSync(SOURCE_DIR)) {
|
||||
execSync(
|
||||
`node ${path.join(root, "scripts", "build.mjs")} --test --rebuild`,
|
||||
{
|
||||
stdio: IS_DEBUG ? "inherit" : "ignore"
|
||||
}
|
||||
);
|
||||
|
||||
const args = [
|
||||
"electron-builder",
|
||||
"--dir",
|
||||
@@ -143,6 +158,7 @@ export async function buildApp(version?: string) {
|
||||
stdio: IS_DEBUG ? "inherit" : "ignore",
|
||||
env: {
|
||||
...process.env,
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false",
|
||||
NOTESNOOK_STAGING: "true",
|
||||
NN_PRODUCT_NAME: productName,
|
||||
NN_APP_ID: `com.notesnook.test.${productName}`,
|
||||
@@ -151,7 +167,7 @@ export async function buildApp(version?: string) {
|
||||
});
|
||||
} catch (e) {
|
||||
if (--MAX_RETRIES) {
|
||||
console.log("retrying...");
|
||||
console.log("retrying...", e);
|
||||
return await buildApp(version);
|
||||
} else throw e;
|
||||
}
|
||||
@@ -189,8 +205,7 @@ async function makeBuildCopyMacOS(outputDir: string, productName: string) {
|
||||
const platformDir = process.arch === "arm64" ? "mac-arm64" : "mac";
|
||||
const appDir = await makeBuildCopy(outputDir, platformDir);
|
||||
return resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
root,
|
||||
appDir,
|
||||
`${productName}.app`,
|
||||
"Contents",
|
||||
@@ -17,24 +17,65 @@ 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 { testCleanup, test } from "./test-override.js";
|
||||
import { test, expect } from "@nn/test";
|
||||
import type { ElectronApplication } from "@playwright/test";
|
||||
|
||||
test("make sure app loads", async ({
|
||||
ctx: { page },
|
||||
expect,
|
||||
onTestFinished
|
||||
}) => {
|
||||
onTestFinished(testCleanup);
|
||||
|
||||
await page.waitForSelector("#authForm");
|
||||
|
||||
expect(
|
||||
await page.getByRole("button", { name: "Create account" }).isVisible()
|
||||
).toBe(true);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Skip & go directly to the app" })
|
||||
.click();
|
||||
async function getMainWindowState(app: ElectronApplication) {
|
||||
return await app.evaluate((window) => {
|
||||
const { BrowserWindow } = window;
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
if (!mainWindow) throw new Error("Main window not found");
|
||||
return {
|
||||
isMinimized: mainWindow.isMinimized(),
|
||||
isVisible: mainWindow.isVisible()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test("make sure app loads", async ({ page }) => {
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
});
|
||||
|
||||
test("hidden launch minimizes when tray is disabled", async ({
|
||||
launchElectronApp,
|
||||
options
|
||||
}) => {
|
||||
const app = await launchElectronApp({
|
||||
version: options.version,
|
||||
args: ["--hidden"],
|
||||
config: {
|
||||
desktopSettings: {
|
||||
minimizeToSystemTray: false,
|
||||
closeToSystemTray: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
|
||||
const state = await getMainWindowState(app);
|
||||
expect(state.isMinimized).toBe(true);
|
||||
});
|
||||
|
||||
test("hidden launch does not minimize when close-to-tray is enabled", async ({
|
||||
launchElectronApp,
|
||||
options
|
||||
}) => {
|
||||
const app = await launchElectronApp({
|
||||
version: options.version,
|
||||
args: ["--hidden"],
|
||||
config: {
|
||||
desktopSettings: {
|
||||
minimizeToSystemTray: false,
|
||||
closeToSystemTray: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForSelector(".ProseMirror");
|
||||
|
||||
const state = await getMainWindowState(app);
|
||||
expect(state.isMinimized).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
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 { test as vitestTest, TestContext } from "vitest";
|
||||
import { buildAndLaunchApp, Fixtures, TestOptions } from "./utils";
|
||||
import { mkdir, rm } from "fs/promises";
|
||||
import path from "path";
|
||||
import slugify from "slugify";
|
||||
|
||||
export const test = vitestTest.extend<Fixtures>({
|
||||
options: { version: "3.0.0" } as TestOptions,
|
||||
ctx: async ({ options }, use) => {
|
||||
const ctx = await buildAndLaunchApp(options);
|
||||
await use(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
export async function testCleanup(context: TestContext) {
|
||||
const ctx = (context.task.context as unknown as Fixtures).ctx;
|
||||
if (context.task.result?.state === "fail") {
|
||||
await mkdir("test-results", { recursive: true });
|
||||
await ctx.page.screenshot({
|
||||
path: path.join(
|
||||
"test-results",
|
||||
`${slugify(context.task.name)}-${process.platform}-${
|
||||
process.arch
|
||||
}-error.png`
|
||||
)
|
||||
});
|
||||
}
|
||||
await ctx.app.close();
|
||||
await rm(ctx.userDataDir, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 5000
|
||||
}).catch(() => {
|
||||
/*ignore */
|
||||
});
|
||||
await rm(ctx.outputDir, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 5000
|
||||
}).catch(() => {
|
||||
/*ignore */
|
||||
});
|
||||
}
|
||||
11
apps/desktop/__tests__/tsconfig.json
Normal file
11
apps/desktop/__tests__/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"lib": ["ESNext"],
|
||||
"moduleResolution": "Bundler",
|
||||
"paths": {
|
||||
"@nn/test": ["./electron-test/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ module.exports = {
|
||||
"node_modules/sodium-native/package.json"
|
||||
],
|
||||
afterPack: "./scripts/removeLocales.js",
|
||||
protocols: [{ name: "Notesnook", schemes: ["nn"] }],
|
||||
mac: {
|
||||
bundleVersion: "240",
|
||||
minimumSystemVersion: "10.12.0",
|
||||
@@ -181,6 +182,7 @@ module.exports = {
|
||||
icon: "assets/icons/app.icns",
|
||||
description: "Your private note taking space",
|
||||
executableName: linuxExecutableName,
|
||||
mimeTypes: ["x-scheme-handler/nn"],
|
||||
desktop: {
|
||||
desktopActions: {
|
||||
"new-note": {
|
||||
|
||||
3330
apps/desktop/package-lock.json
generated
3330
apps/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"name": "@notesnook/desktop",
|
||||
"productName": "Notesnook",
|
||||
"description": "Your private note taking space",
|
||||
"version": "3.3.17",
|
||||
"version": "3.4.5",
|
||||
"appAppleId": "1544027013",
|
||||
"private": true,
|
||||
"main": "./dist/cjs/index.js",
|
||||
@@ -21,6 +21,17 @@
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
}
|
||||
},
|
||||
"./testutils": "./__tests__/test-override.ts",
|
||||
"./*": {
|
||||
"require": {
|
||||
"types": "./dist/types/*",
|
||||
"default": "./dist/cjs/*"
|
||||
},
|
||||
"import": {
|
||||
"types": "./dist/types/*",
|
||||
"default": "./dist/esm/*"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homepage": "https://notesnook.com/",
|
||||
@@ -43,16 +54,18 @@
|
||||
"zod": "3.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.1",
|
||||
"@streetwriters/kysely": "^0.27.4",
|
||||
"@types/node": "22.15.3",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"chokidar": "^4.0.3",
|
||||
"electron": "^37.0.0",
|
||||
"electron": "^37.10.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"esbuild": "0.21.5",
|
||||
"fkill": "^10.0.3",
|
||||
"node-abi": "^4.5.0",
|
||||
"node-gyp-build": "^4.8.4",
|
||||
"playwright": "1.48.2",
|
||||
"playwright-core": "1.61.1",
|
||||
"prebuildify": "^6.0.1",
|
||||
"slugify": "1.6.6",
|
||||
"tree-kill": "^1.2.2",
|
||||
@@ -73,7 +86,7 @@
|
||||
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
|
||||
"bundle:mas": "esbuild electron=./src/main.ts ./src/preload.ts --minify --external:electron --external:fsevents --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=true --define:RELEASE=true",
|
||||
"postinstall": "patch-package",
|
||||
"test": "vitest run"
|
||||
"test": "playwright test --tsconfig __tests__/tsconfig.json --project notesnook-desktop"
|
||||
},
|
||||
"author": {
|
||||
"name": "Streetwriters (Private) Limited",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
diff --git a/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js b/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
|
||||
index 7d8468f..ab252ef 100644
|
||||
--- a/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
|
||||
+++ b/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
|
||||
@@ -16010,6 +16010,7 @@ var init_validator = __esm({
|
||||
args: tOptional(tArray(tString)),
|
||||
chromiumSandbox: tOptional(tBoolean),
|
||||
cwd: tOptional(tString),
|
||||
+ baseURL: tOptional(tString),
|
||||
env: tOptional(tArray(tType("NameValue"))),
|
||||
timeout: tFloat,
|
||||
acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])),
|
||||
diff --git a/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts b/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
|
||||
index 0bff9d3..d195697 100644
|
||||
--- a/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
|
||||
+++ b/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
|
||||
@@ -21813,6 +21813,7 @@ export interface Electron {
|
||||
* Additional arguments to pass to the application when launching. You typically pass the main script name here.
|
||||
*/
|
||||
args?: Array<string>;
|
||||
+ baseURL?: string;
|
||||
|
||||
/**
|
||||
* If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory
|
||||
92
apps/desktop/playwright.config.ts
Normal file
92
apps/desktop/playwright.config.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable header/header */
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
PlaywrightTestOptions,
|
||||
PlaywrightWorkerOptions
|
||||
} from "@playwright/test";
|
||||
import * as path from "path";
|
||||
|
||||
process.env.PWPAGE_IMPL = "electron";
|
||||
process.env.TEST_DESKTOP = "true";
|
||||
const outputDir = path.join(__dirname, "test-results");
|
||||
const testDir = path.join(__dirname, "__tests__");
|
||||
const config: Config<PlaywrightWorkerOptions & PlaywrightTestOptions> = {
|
||||
testDir,
|
||||
outputDir,
|
||||
expect: {
|
||||
timeout: 10000
|
||||
},
|
||||
use: {
|
||||
acceptDownloads: true,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retry-with-video",
|
||||
viewport: {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
}
|
||||
},
|
||||
timeout: 60000,
|
||||
globalTimeout: 5400000,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI
|
||||
? [["dot"], ["json", { outputFile: path.join(outputDir, "report.json") }]]
|
||||
: "line",
|
||||
projects: [],
|
||||
globalSetup: "./__tests__/electron-test/global-setup.ts"
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
platform: process.platform,
|
||||
headless: "headed",
|
||||
browserName: "electron",
|
||||
channel: undefined,
|
||||
mode: "default",
|
||||
video: false
|
||||
};
|
||||
|
||||
config.projects?.push({
|
||||
name: "notesnook-desktop",
|
||||
// Share screenshots with chromium.
|
||||
snapshotPathTemplate:
|
||||
"{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-electron{ext}",
|
||||
use: {
|
||||
browserName: "chromium",
|
||||
headless: false
|
||||
},
|
||||
testDir: "__tests__",
|
||||
metadata
|
||||
});
|
||||
|
||||
config.projects?.push({
|
||||
name: "notesnook-web",
|
||||
// Share screenshots with chromium.
|
||||
snapshotPathTemplate:
|
||||
"{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-electron{ext}",
|
||||
use: {
|
||||
browserName: "chromium",
|
||||
headless: false
|
||||
},
|
||||
testDir: path.resolve(__dirname, "../web/__e2e__"),
|
||||
metadata
|
||||
});
|
||||
|
||||
export default config;
|
||||
@@ -33,6 +33,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const root = args.root || path.join(__dirname, "..");
|
||||
const skipTscBuild = args.skipTscBuild || false;
|
||||
const buildForTesting = args.test || false;
|
||||
|
||||
const webAppPath = path.resolve(path.join(__dirname, "..", "..", "web"));
|
||||
|
||||
@@ -51,7 +52,11 @@ console.log("removed build folder");
|
||||
if (args.rebuild || !existsSync(path.join(webAppPath, "build"))) {
|
||||
console.log("rebuilding...");
|
||||
await exec(
|
||||
"node scripts/execute.mjs @notesnook/web:build:desktop",
|
||||
`node scripts/execute.mjs ${
|
||||
buildForTesting
|
||||
? "@notesnook/web:build:test:desktop"
|
||||
: "@notesnook/web:build:desktop"
|
||||
}`,
|
||||
path.join(__dirname, "..", "..", "..")
|
||||
);
|
||||
}
|
||||
|
||||
108
apps/desktop/src/api/backups.ts
Normal file
108
apps/desktop/src/api/backups.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
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 { initTRPC } from "@trpc/server";
|
||||
import { createWriteStream, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { config } from "../utils/config";
|
||||
import { resolvePath } from "../utils/resolve-path";
|
||||
import { app } from "electron";
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const activeStreams = new Map<string, NodeJS.WritableStream>();
|
||||
|
||||
function generateId() {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function getStreamOrThrow(id: string, operation: "write" | "close") {
|
||||
const stream = activeStreams.get(id);
|
||||
if (!stream) {
|
||||
throw new Error(
|
||||
`Backup stream not found during ${operation}. The stream may have already been closed or was never opened. Stream id: ${id}`
|
||||
);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
export const backupsRouter = t.router({
|
||||
open: t.procedure
|
||||
.input(z.object({ filename: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const { filename } = input;
|
||||
if (!filename.trim()) {
|
||||
throw new Error("Invalid backup filename: filename cannot be empty.");
|
||||
}
|
||||
if (filename.includes(path.sep) || filename.includes("\\"))
|
||||
throw new Error(
|
||||
`Invalid backup filename: expected a plain file name without path separators, received "${filename}".`
|
||||
);
|
||||
const resolvedBackupDir = resolvePath(config.backupDirectory);
|
||||
const backupPath = path.resolve(resolvedBackupDir, filename);
|
||||
const relativeBackupPath = path.relative(resolvedBackupDir, backupPath);
|
||||
if (
|
||||
relativeBackupPath.startsWith("..") ||
|
||||
path.isAbsolute(relativeBackupPath)
|
||||
)
|
||||
throw new Error(
|
||||
`Invalid backup filename: resolved path "${backupPath}" is outside the configured backup directory "${resolvedBackupDir}". The configured backup directory may be invalid.`
|
||||
);
|
||||
|
||||
mkdirSync(resolvedBackupDir, { recursive: true });
|
||||
const stream = createWriteStream(backupPath, { encoding: "utf-8" });
|
||||
const id = generateId();
|
||||
activeStreams.set(id, stream);
|
||||
return id;
|
||||
}),
|
||||
|
||||
write: t.procedure
|
||||
.input(z.object({ id: z.string(), chunk: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const stream = getStreamOrThrow(input.id, "write");
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
stream.write(Buffer.from(input.chunk, "base64"), (err) =>
|
||||
err ? reject(err) : resolve()
|
||||
);
|
||||
});
|
||||
}),
|
||||
|
||||
close: t.procedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const stream = getStreamOrThrow(input.id, "close");
|
||||
return new Promise<void>((resolve) => {
|
||||
stream.end(() => {
|
||||
activeStreams.delete(input.id);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
try {
|
||||
for (const stream of activeStreams.values()) {
|
||||
stream.end();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
@@ -24,21 +24,43 @@ import TypedEventEmitter from "typed-emitter";
|
||||
|
||||
export type AppEvents = {
|
||||
onCreateItem(name: "note" | "notebook" | "reminder"): void;
|
||||
onOpenLink(url: string): void;
|
||||
bridgeReady(): void;
|
||||
};
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const typedEmitter = emitter as TypedEventEmitter<AppEvents>;
|
||||
let isBridgeReady = false;
|
||||
const pendingEvents: { name: string; args: unknown[] }[] = [];
|
||||
const _emitter = new EventEmitter();
|
||||
const emitter = _emitter as TypedEventEmitter<AppEvents>;
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const bridgeRouter = t.router({
|
||||
onCreateItem: createSubscription("onCreateItem")
|
||||
onCreateItem: createSubscription("onCreateItem"),
|
||||
onOpenLink: createSubscription("onOpenLink"),
|
||||
ready: t.procedure.query(() => {
|
||||
isBridgeReady = true;
|
||||
if (pendingEvents.length > 0) {
|
||||
console.log(
|
||||
"Emitting pending events",
|
||||
pendingEvents.map((e) => e.name)
|
||||
);
|
||||
pendingEvents.forEach((event) => {
|
||||
emitter.emit(event.name as any, ...(event.args as any[]));
|
||||
});
|
||||
pendingEvents.length = 0;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
});
|
||||
|
||||
export const bridge: AppEvents = new Proxy({} as AppEvents, {
|
||||
get(_t, name) {
|
||||
if (typeof name === "symbol") return;
|
||||
return (...args: unknown[]) => {
|
||||
emitter.emit(name, ...args);
|
||||
if (!isBridgeReady) {
|
||||
pendingEvents.push({ name, args });
|
||||
return;
|
||||
}
|
||||
_emitter.emit(name, ...args);
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -49,9 +71,9 @@ function createSubscription<TName extends keyof AppEvents>(eventName: TName) {
|
||||
const listener: AppEvents[TName] = (...args: any[]) => {
|
||||
emit.next(args[0]);
|
||||
};
|
||||
typedEmitter.addListener(eventName, listener);
|
||||
emitter.addListener(eventName, listener);
|
||||
return () => {
|
||||
typedEmitter.removeListener(eventName, listener);
|
||||
emitter.removeListener(eventName, listener);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import { updaterRouter } from "./updater";
|
||||
import { bridgeRouter } from "./bridge";
|
||||
import { safeStorageRouter } from "./safe-storage";
|
||||
import { windowRouter } from "./window";
|
||||
import { sqliteRouter } from "./sqlite-kysely";
|
||||
import { backupsRouter } from "./backups";
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
@@ -35,10 +37,13 @@ export const router = t.router({
|
||||
updater: updaterRouter,
|
||||
bridge: bridgeRouter,
|
||||
safeStorage: safeStorageRouter,
|
||||
window: windowRouter
|
||||
window: windowRouter,
|
||||
sqlite: sqliteRouter,
|
||||
backups: backupsRouter
|
||||
});
|
||||
|
||||
export const api = router.createCaller({});
|
||||
const createCaller = t.createCallerFactory(router);
|
||||
export const api = createCaller({});
|
||||
|
||||
// Export type router type signature,
|
||||
// NOT the router itself.
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
dialog,
|
||||
Menu,
|
||||
MenuItem,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
Notification,
|
||||
shell
|
||||
@@ -33,16 +32,16 @@ import { AutoLaunch } from "../utils/autolaunch";
|
||||
import { config, DesktopIntegration } from "../utils/config";
|
||||
import { bringToFront } from "../utils/bring-to-front";
|
||||
import { getTheme, setTheme, Theme } from "../utils/theme";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
import { resolvePath } from "../utils/resolve-path";
|
||||
import { existsSync } from "fs";
|
||||
import { normalizePathString, resolvePath } from "../utils/resolve-path";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { AssetManager } from "../utils/asset-manager";
|
||||
import { isFlatpak, isSnap } from "../utils";
|
||||
import { isFlatpak, isPortable, isSnap } from "../utils";
|
||||
import { setupDesktopIntegration } from "../utils/desktop-integration";
|
||||
import { rm } from "fs/promises";
|
||||
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
|
||||
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
|
||||
import { platform } from "os";
|
||||
import { strings } from "@notesnook/intl";
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
@@ -60,6 +59,14 @@ const NotificationOptions = z.object({
|
||||
export const osIntegrationRouter = t.router({
|
||||
isFlatpak: t.procedure.query(() => isFlatpak()),
|
||||
isSnap: t.procedure.query(() => isSnap()),
|
||||
isPortable: t.procedure.query(() => isPortable()),
|
||||
backupDirectory: t.procedure.query(() => {
|
||||
const backupDirectory = normalizePathString(config.backupDirectory);
|
||||
if (backupDirectory !== config.backupDirectory) {
|
||||
config.backupDirectory = backupDirectory;
|
||||
}
|
||||
return backupDirectory;
|
||||
}),
|
||||
|
||||
zoomFactor: t.procedure.query(() => config.zoomFactor),
|
||||
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
|
||||
@@ -116,53 +123,19 @@ export const osIntegrationRouter = t.router({
|
||||
setupDesktopIntegration(settings);
|
||||
}),
|
||||
|
||||
selectDirectory: t.procedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
buttonLabel: z.string().optional(),
|
||||
defaultPath: z.string().optional()
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
if (!globalThis.window) return undefined;
|
||||
selectBackupDirectory: t.procedure.input(z.undefined()).query(async () => {
|
||||
if (!globalThis.window) return undefined;
|
||||
|
||||
const { title, buttonLabel, defaultPath } = input;
|
||||
const result = await dialog.showOpenDialog(globalThis.window, {
|
||||
title: strings.selectBackupDir(),
|
||||
buttonLabel: strings.select(),
|
||||
properties: ["openDirectory"],
|
||||
defaultPath: config.backupDirectory && resolvePath(config.backupDirectory)
|
||||
});
|
||||
if (result.canceled) return undefined;
|
||||
|
||||
const result = await dialog.showOpenDialog(globalThis.window, {
|
||||
title,
|
||||
buttonLabel,
|
||||
properties: ["openDirectory"],
|
||||
defaultPath: defaultPath && resolvePath(defaultPath)
|
||||
});
|
||||
if (result.canceled) return undefined;
|
||||
|
||||
return result.filePaths[0];
|
||||
}),
|
||||
|
||||
saveFile: t.procedure
|
||||
.input(z.object({ data: z.string(), filePath: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const { data, filePath } = input;
|
||||
if (!data || !filePath) return;
|
||||
|
||||
const resolvedPath = resolvePath(filePath);
|
||||
|
||||
mkdirSync(dirname(resolvedPath), { recursive: true });
|
||||
writeFileSync(resolvedPath, data);
|
||||
}),
|
||||
|
||||
resolvePath: t.procedure
|
||||
.input(z.object({ filePath: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const { filePath } = input;
|
||||
return resolvePath(filePath);
|
||||
}),
|
||||
|
||||
deleteFile: t.procedure.input(z.string()).query(async ({ input }) => {
|
||||
await rm(input);
|
||||
config.backupDirectory = result.filePaths[0];
|
||||
}),
|
||||
|
||||
restart: t.procedure.query(() => {
|
||||
app.relaunch();
|
||||
app.exit();
|
||||
@@ -191,9 +164,38 @@ export const osIntegrationRouter = t.router({
|
||||
}),
|
||||
openPath: t.procedure
|
||||
.input(z.object({ type: z.literal("path"), link: z.string() }))
|
||||
.query(({ input }) => {
|
||||
.query(async ({ input }) => {
|
||||
if (isFlatpak()) return;
|
||||
|
||||
const { type, link } = input;
|
||||
if (type === "path") return shell.openPath(resolvePath(link));
|
||||
if (type !== "path") return;
|
||||
|
||||
const path = decodeURIComponent(new URL(link).pathname);
|
||||
const resolvedPath = resolvePath(
|
||||
// remove leading slash from path on windows
|
||||
platform() === "win32" ? path.slice(1) : path
|
||||
);
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
if (globalThis.window) {
|
||||
await dialog.showMessageBox(globalThis.window, {
|
||||
type: "error",
|
||||
title: "Path not found",
|
||||
message: `The path does not exist:\n${wrapPath(resolvedPath)}`
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dialog.showMessageBox(globalThis.window!, {
|
||||
message: strings.openingLocalFileDesc(resolvedPath),
|
||||
title: strings.openingLocalFile(),
|
||||
buttons: [strings.cancel(), strings.open()],
|
||||
defaultId: 1,
|
||||
cancelId: 0,
|
||||
type: "question"
|
||||
});
|
||||
result.response === 1 && (await shell.openPath(resolvedPath));
|
||||
}),
|
||||
bringToFront: t.procedure.query(() => bringToFront()),
|
||||
changeTheme: t.procedure
|
||||
@@ -298,3 +300,7 @@ function toMenuItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wrapPath(path: string, maxLineLength = 100): string {
|
||||
return path.replace(new RegExp(`(.{${maxLineLength}})`, "g"), "$1\n");
|
||||
}
|
||||
|
||||
@@ -84,35 +84,55 @@ const LANGUAGES: Record<string, string> = {
|
||||
|
||||
type Language = { code: string; name: string };
|
||||
|
||||
const LANGUAGE_REDIRECT_MAP: Record<string, string> = {
|
||||
es: "es-MX",
|
||||
"es-419": "es-MX",
|
||||
"es-ES": "es-AR"
|
||||
};
|
||||
|
||||
export const spellCheckerRouter = t.router({
|
||||
isEnabled: t.procedure.query(() => config.isSpellCheckerEnabled),
|
||||
languages: t.procedure.query(
|
||||
() =>
|
||||
<Language[]>(
|
||||
globalThis.window?.webContents.session.availableSpellCheckerLanguages.map(
|
||||
(code) => ({
|
||||
code,
|
||||
name: LANGUAGES[code]
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
enabledLanguages: t.procedure.query(
|
||||
() =>
|
||||
<Language[]>(
|
||||
globalThis.window?.webContents.session
|
||||
.getSpellCheckerLanguages()
|
||||
.map((code) => ({
|
||||
code,
|
||||
name: LANGUAGES[code]
|
||||
}))
|
||||
)
|
||||
),
|
||||
setLanguages: t.procedure
|
||||
.input(z.array(z.string()))
|
||||
.mutation(({ input: languages }) =>
|
||||
globalThis.window?.webContents.session.setSpellCheckerLanguages(languages)
|
||||
),
|
||||
languages: t.procedure.query(() => {
|
||||
const available =
|
||||
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
|
||||
[];
|
||||
|
||||
return <Language[]>available
|
||||
.map((code) => ({
|
||||
code,
|
||||
name: LANGUAGES[code] || code
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}),
|
||||
|
||||
enabledLanguages: t.procedure.query(() => {
|
||||
const enabled =
|
||||
globalThis.window?.webContents.session.getSpellCheckerLanguages() || [];
|
||||
const available =
|
||||
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
|
||||
[];
|
||||
|
||||
const resolved = enabled
|
||||
.map((code) => resolveLanguage(code, available))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
return <Language[]>resolved.map((code) => ({
|
||||
code,
|
||||
name: LANGUAGES[code] || code
|
||||
}));
|
||||
}),
|
||||
|
||||
setLanguages: t.procedure.input(z.array(z.string())).mutation(({ input }) => {
|
||||
const available =
|
||||
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
|
||||
[];
|
||||
|
||||
const resolved = input
|
||||
.map((code) => resolveLanguage(code, available))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
globalThis.window?.webContents.session.setSpellCheckerLanguages(resolved);
|
||||
}),
|
||||
toggle: t.procedure
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.mutation(({ input: { enabled } }) => {
|
||||
@@ -128,3 +148,16 @@ export const spellCheckerRouter = t.router({
|
||||
);
|
||||
})
|
||||
});
|
||||
|
||||
function resolveLanguage(code: string, available: string[]) {
|
||||
if (LANGUAGE_REDIRECT_MAP[code]) {
|
||||
const working = LANGUAGE_REDIRECT_MAP[code];
|
||||
return available.includes(working) ? working : code;
|
||||
}
|
||||
const fallback = code.split("-")[0];
|
||||
return available.includes(code)
|
||||
? code
|
||||
: available.includes(fallback)
|
||||
? fallback
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import type { Database, Statement } from "better-sqlite3-multiple-ciphers";
|
||||
import type { QueryResult } from "@streetwriters/kysely";
|
||||
import { app } from "electron";
|
||||
import path from "node:path";
|
||||
import { initTRPC } from "@trpc/server";
|
||||
|
||||
type SQLiteCompatibleType =
|
||||
| number
|
||||
@@ -28,25 +31,32 @@ type SQLiteCompatibleType =
|
||||
| bigint
|
||||
| null;
|
||||
|
||||
export class SQLite {
|
||||
class SQLite {
|
||||
sqlite?: Database;
|
||||
initialized = false;
|
||||
preparedStatements: Map<string, Statement<unknown[]>> = new Map();
|
||||
retryCounter: Record<string, number> = {};
|
||||
extensionsLoaded = false;
|
||||
private filePath?: string;
|
||||
|
||||
constructor() {
|
||||
console.log("new sqlite worker");
|
||||
}
|
||||
|
||||
async open(filePath: string) {
|
||||
async open(filename: string) {
|
||||
if (this.sqlite) {
|
||||
console.error("Database is already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
this.filePath =
|
||||
filename === ":memory:"
|
||||
? filename
|
||||
: path.join(app.getPath("userData"), filename) + ".sql";
|
||||
if (!isPathAllowed(this.filePath))
|
||||
throw new Error("Database path is not allowed: " + this.filePath);
|
||||
this.sqlite = require("better-sqlite3-multiple-ciphers")(
|
||||
filePath
|
||||
this.filePath
|
||||
).unsafeMode(true);
|
||||
}
|
||||
|
||||
@@ -155,9 +165,11 @@ export class SQLite {
|
||||
this.sqlite = undefined;
|
||||
}
|
||||
|
||||
async delete(filePath: string) {
|
||||
async delete() {
|
||||
if (!this.filePath) return;
|
||||
|
||||
await this.close();
|
||||
await require("fs/promises").rm(filePath, {
|
||||
await require("node:fs/promises").rm(this.filePath, {
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 500
|
||||
@@ -221,3 +233,67 @@ function rewriteError(e: Error, message: string) {
|
||||
error.cause = e.cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isPathAllowed(databasePath: string) {
|
||||
if (databasePath === ":memory:") return true;
|
||||
const base = app.getPath("userData");
|
||||
const resolved = path.resolve(databasePath);
|
||||
return resolved.startsWith(base + path.sep);
|
||||
}
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
const databases: Record<string, SQLite> = {};
|
||||
|
||||
export const sqliteRouter = t.router({
|
||||
open: t.procedure
|
||||
.input((v) => v)
|
||||
.mutation(async ({ input }) => {
|
||||
const { filePath } = input as { filePath: string };
|
||||
if (databases[filePath]) return filePath;
|
||||
const sqlite = new SQLite();
|
||||
await sqlite.open(filePath);
|
||||
databases[filePath] = sqlite;
|
||||
return filePath;
|
||||
}),
|
||||
run: t.procedure
|
||||
.input((v) => v)
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, sql, parameters } = input as {
|
||||
id: string;
|
||||
sql: string;
|
||||
parameters?: SQLiteCompatibleType[];
|
||||
};
|
||||
const sqlite = databases[id];
|
||||
if (!sqlite) throw new Error("Database not found for id: " + id);
|
||||
return await sqlite.run(sql, parameters);
|
||||
}),
|
||||
close: t.procedure
|
||||
.input((v) => v)
|
||||
.mutation(async ({ input }) => {
|
||||
const { id } = input as { id: string };
|
||||
const sqlite = databases[id];
|
||||
if (!sqlite) throw new Error("Database not found for id: " + id);
|
||||
await sqlite.close();
|
||||
delete databases[id];
|
||||
}),
|
||||
delete: t.procedure
|
||||
.input((v) => v)
|
||||
.mutation(async ({ input }) => {
|
||||
const { id } = input as { id: string };
|
||||
const sqlite = databases[id];
|
||||
if (!sqlite) throw new Error("Database not found for id: " + id);
|
||||
await sqlite.delete();
|
||||
delete databases[id];
|
||||
})
|
||||
});
|
||||
|
||||
app.on("before-quit", async () => {
|
||||
for (const db of Object.values(databases)) {
|
||||
try {
|
||||
await db.close();
|
||||
} catch (e) {
|
||||
console.error("Error closing database:", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { AppUpdaterEvents } from "electron-updater/out/AppUpdater";
|
||||
import { z } from "zod";
|
||||
import { config } from "../utils/config";
|
||||
import { app } from "electron";
|
||||
import { isFlatpak, isPortable, isSnap } from "../utils";
|
||||
|
||||
type UpdateInfo = { version: string };
|
||||
type Progress = { percent: number };
|
||||
@@ -31,13 +32,15 @@ type Progress = { percent: number };
|
||||
const t = initTRPC.create();
|
||||
let cancellationToken: CancellationToken | undefined = undefined;
|
||||
let downloadTimeout: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
const updatesSupported = !isFlatpak() && !isSnap() && !isPortable();
|
||||
export const updaterRouter = t.router({
|
||||
autoUpdates: t.procedure.query(() => config.automaticUpdates),
|
||||
autoUpdates: t.procedure.query(
|
||||
() => updatesSupported && config.automaticUpdates
|
||||
),
|
||||
releaseTrack: t.procedure.query(() => config.releaseTrack),
|
||||
install: t.procedure.query(() => autoUpdater.quitAndInstall()),
|
||||
download: t.procedure.query(async () => {
|
||||
if (cancellationToken) return;
|
||||
if (!updatesSupported || cancellationToken) return;
|
||||
clearTimeout(downloadTimeout);
|
||||
await new Promise<string[]>((resolve, reject) => {
|
||||
downloadTimeout = setTimeout(async () => {
|
||||
@@ -52,7 +55,7 @@ export const updaterRouter = t.router({
|
||||
});
|
||||
}),
|
||||
check: t.procedure.query(async () => {
|
||||
if (cancellationToken) return;
|
||||
if (!updatesSupported || cancellationToken) return;
|
||||
clearTimeout(downloadTimeout);
|
||||
await new Promise<void>((resolve) => {
|
||||
downloadTimeout = setTimeout(async () => {
|
||||
|
||||
@@ -21,4 +21,3 @@ export { PATHS } from "./constants";
|
||||
export type { AppRouter } from "./api";
|
||||
export { type UpdateInfo } from "builder-util-runtime";
|
||||
export { type DesktopIntegration } from "./utils/config";
|
||||
export { SQLite } from "./api/sqlite-kysely.js";
|
||||
|
||||
@@ -39,6 +39,8 @@ import { setupDesktopIntegration } from "./utils/desktop-integration";
|
||||
import { disableCustomDns, enableCustomDns } from "./utils/custom-dns";
|
||||
import { Messages, setI18nGlobal } from "@notesnook/intl";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { PATHS } from "./constants";
|
||||
import { normalizePathString } from "./utils/resolve-path";
|
||||
|
||||
const locale =
|
||||
process.env.NODE_ENV === "development"
|
||||
@@ -55,6 +57,10 @@ setI18nGlobal(i18n);
|
||||
const appHostnames = isDevelopment()
|
||||
? ["localhost", "127.0.0.1"]
|
||||
: ["app.notesnook.com"];
|
||||
// Pending nn:// link to open once the window is ready (used on Windows/Linux
|
||||
// when the app is launched via the nn:// protocol for the first time).
|
||||
let pendingNNLink: string | undefined = findNNLink(process.argv);
|
||||
|
||||
// only run a single instance
|
||||
if (!MAC_APP_STORE && !app.requestSingleInstanceLock()) {
|
||||
console.log("Another instance is already running!");
|
||||
@@ -86,6 +92,17 @@ async function createWindow() {
|
||||
const cliOptions = await parseArguments(process.argv);
|
||||
setTheme(getTheme());
|
||||
|
||||
// this workaround is necessary because macos doesn't support
|
||||
// the --hidden flag when launching the app on startup
|
||||
if (
|
||||
process.platform === "darwin" &&
|
||||
app.getLoginItemSettings().wasOpenedAtLogin &&
|
||||
config.desktopSettings.autoStart &&
|
||||
config.desktopSettings.startMinimized
|
||||
) {
|
||||
cliOptions.hidden = true;
|
||||
}
|
||||
|
||||
const mainWindowState = new WindowState({});
|
||||
const mainWindow = new BrowserWindow({
|
||||
show: !cliOptions.hidden,
|
||||
@@ -125,9 +142,6 @@ async function createWindow() {
|
||||
|
||||
webPreferences: {
|
||||
zoomFactor: config.zoomFactor,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
nodeIntegrationInWorker: true,
|
||||
spellcheck: config.isSpellCheckerEnabled,
|
||||
preload: __dirname + "/preload.js"
|
||||
}
|
||||
@@ -138,7 +152,13 @@ async function createWindow() {
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
mainWindowState.manage(mainWindow);
|
||||
|
||||
if (cliOptions.hidden && !config.desktopSettings.minimizeToSystemTray)
|
||||
if (
|
||||
cliOptions.hidden &&
|
||||
!(
|
||||
config.desktopSettings.minimizeToSystemTray ||
|
||||
config.desktopSettings.closeToSystemTray
|
||||
)
|
||||
)
|
||||
mainWindow.minimize();
|
||||
|
||||
await mainWindow.webContents.loadURL(`${createURL(cliOptions, "/")}`);
|
||||
@@ -161,6 +181,14 @@ async function createWindow() {
|
||||
);
|
||||
mainWindow.webContents.session.setProxy({ proxyRules: config.proxyRules });
|
||||
|
||||
mainWindow.on("show", () =>
|
||||
/**
|
||||
* We may set `skipTaskbar` to true at startup.
|
||||
* This also removes the window from the Alt-Tab switcher.
|
||||
* To fix that, whenever the app is shown, we set `skipTaskbar` to false.
|
||||
*/
|
||||
mainWindow.setSkipTaskbar(false)
|
||||
);
|
||||
mainWindow.once("closed", () => {
|
||||
globalThis.window = null;
|
||||
});
|
||||
@@ -193,6 +221,11 @@ async function createWindow() {
|
||||
setupTray();
|
||||
setupJumplist();
|
||||
});
|
||||
|
||||
if (pendingNNLink) {
|
||||
bridge.onOpenLink(pendingNNLink);
|
||||
pendingNNLink = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
app.once("ready", async () => {
|
||||
@@ -212,9 +245,12 @@ app.once("ready", async () => {
|
||||
if (config.customDns) enableCustomDns();
|
||||
else disableCustomDns();
|
||||
|
||||
if (!MAC_APP_STORE) app.setAsDefaultProtocolClient("nn");
|
||||
|
||||
if (!isDevelopment()) registerProtocol();
|
||||
await createWindow();
|
||||
configureAutoUpdater();
|
||||
await migrateBackupDirectory();
|
||||
await configureAutoUpdater();
|
||||
});
|
||||
|
||||
app.once("window-all-closed", () => {
|
||||
@@ -225,6 +261,12 @@ app.once("window-all-closed", () => {
|
||||
|
||||
app.on("second-instance", async (_ev, argv) => {
|
||||
if (!globalThis.window) return;
|
||||
const nnLink = findNNLink(argv);
|
||||
if (nnLink) {
|
||||
bridge.onOpenLink(nnLink);
|
||||
bringToFront();
|
||||
return;
|
||||
}
|
||||
const cliOptions = await parseArguments(argv);
|
||||
if (cliOptions.note) bridge.onCreateItem("note");
|
||||
if (cliOptions.notebook) bridge.onCreateItem("notebook");
|
||||
@@ -232,12 +274,29 @@ app.on("second-instance", async (_ev, argv) => {
|
||||
bringToFront();
|
||||
});
|
||||
|
||||
// macOS opens URLs via this event. The app may or may not be fully loaded yet.
|
||||
app.on("open-url", (event, url) => {
|
||||
event.preventDefault();
|
||||
if (!url.startsWith("nn://")) return;
|
||||
if (globalThis.window) {
|
||||
bridge.onOpenLink(url);
|
||||
bringToFront();
|
||||
} else {
|
||||
// Window not ready yet — store for when createWindow finishes loading.
|
||||
pendingNNLink = url;
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (globalThis.window === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
function findNNLink(argv: string[]): string | undefined {
|
||||
return argv.find((arg) => arg.startsWith("nn://"));
|
||||
}
|
||||
|
||||
function createURL(options: CLIOptions, path = "/") {
|
||||
const url = new URL(isDevelopment() ? "http://localhost:3000" : PROTOCOL_URL);
|
||||
|
||||
@@ -248,7 +307,31 @@ function createURL(options: CLIOptions, path = "/") {
|
||||
else if (typeof options.note === "string")
|
||||
url.hash = `/notes/${options.note}/edit`;
|
||||
else if (typeof options.notebook === "string")
|
||||
url.hash = `/notebooks/${options.notebook}`;
|
||||
url.pathname = `/notebooks/${options.notebook}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function migrateBackupDirectory() {
|
||||
if (!globalThis.window) return;
|
||||
try {
|
||||
if (config.backupDirectory !== PATHS.backupsDirectory) return;
|
||||
const oldPath = await globalThis.window?.webContents.executeJavaScript(
|
||||
`localStorage.getItem("backupStorageLocation")`
|
||||
);
|
||||
if (!oldPath || oldPath === PATHS.backupsDirectory) return;
|
||||
config.backupDirectory = normalizePathString(oldPath);
|
||||
} catch (e) {
|
||||
console.error("Failed to migrate backup directory", e);
|
||||
const pressedButton = dialog.showMessageBoxSync(globalThis.window, {
|
||||
message:
|
||||
"Failed to migrate backup directory. It has been reset to default.",
|
||||
title: "Backup Directory Migration Failed",
|
||||
type: "error",
|
||||
buttons: ["Set backup directory", "Ignore"]
|
||||
});
|
||||
if (pressedButton === 0) {
|
||||
await api.integration.selectBackupDirectory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,25 +19,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/* eslint-disable no-var */
|
||||
|
||||
import { ELECTRON_TRPC_CHANNEL } from "electron-trpc/main";
|
||||
// import type { NNCrypto } from "@notesnook/crypto";
|
||||
import { ipcRenderer } from "electron";
|
||||
import { platform } from "os";
|
||||
import { ipcRenderer, contextBridge } from "electron";
|
||||
|
||||
declare global {
|
||||
var os: () => "mas" | ReturnType<typeof platform>;
|
||||
var os: () => "mas" | typeof process.platform;
|
||||
var electronTRPC: any;
|
||||
// var NativeNNCrypto: (new () => NNCrypto) | undefined;
|
||||
}
|
||||
|
||||
process.once("loaded", async () => {
|
||||
const electronTRPC = {
|
||||
sendMessage: (operation: any) =>
|
||||
ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation),
|
||||
onMessage: (callback: any) =>
|
||||
ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args))
|
||||
};
|
||||
globalThis.electronTRPC = electronTRPC;
|
||||
});
|
||||
const electronTRPC = {
|
||||
sendMessage: (operation: any) =>
|
||||
ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation),
|
||||
onMessage: (callback: any) =>
|
||||
ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args))
|
||||
};
|
||||
|
||||
// globalThis.NativeNNCrypto = require("@notesnook/crypto").NNCrypto;
|
||||
globalThis.os = () => (MAC_APP_STORE ? "mas" : platform());
|
||||
const os = () => (MAC_APP_STORE ? "mas" : process.platform);
|
||||
|
||||
contextBridge.exposeInMainWorld("electronTRPC", electronTRPC);
|
||||
contextBridge.exposeInMainWorld("os", os);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { nativeTheme } from "electron";
|
||||
import { JSONStorage } from "./json-storage";
|
||||
import { z } from "zod";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { PATHS } from "../constants";
|
||||
|
||||
export const DesktopIntegration = z.object({
|
||||
autoStart: z.boolean().optional(),
|
||||
@@ -53,7 +54,8 @@ export const config = {
|
||||
|
||||
backgroundColor: nativeTheme.themeSource === "dark" ? "#0f0f0f" : "#ffffff",
|
||||
windowControlsIconColor:
|
||||
nativeTheme.themeSource === "dark" ? "#ffffff" : "#000000"
|
||||
nativeTheme.themeSource === "dark" ? "#ffffff" : "#000000",
|
||||
backupDirectory: PATHS.backupsDirectory
|
||||
};
|
||||
|
||||
type ConfigKey = keyof typeof config;
|
||||
|
||||
@@ -33,3 +33,7 @@ export function isFlatpak() {
|
||||
export function isSnap() {
|
||||
return process.env.SNAP !== undefined;
|
||||
}
|
||||
|
||||
export function isPortable() {
|
||||
return process.env.PORTABLE_EXECUTABLE_DIR !== undefined;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { app } from "electron";
|
||||
import { isAbsolute, join } from "path";
|
||||
|
||||
export function normalizePathString(_path: string) {
|
||||
let normalizedPath = _path.trim();
|
||||
|
||||
try {
|
||||
const parsedPath = JSON.parse(normalizedPath);
|
||||
if (typeof parsedPath === "string") normalizedPath = parsedPath;
|
||||
} catch {
|
||||
// ignore invalid JSON and keep the original path string
|
||||
}
|
||||
|
||||
if (normalizedPath === "~") return app.getPath("home");
|
||||
if (/^~[\\/]/.test(normalizedPath)) {
|
||||
return join(app.getPath("home"), normalizedPath.slice(2));
|
||||
}
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
export function resolvePath(_path: string) {
|
||||
if (isAbsolute(_path)) return _path;
|
||||
const normalizedPath = normalizePathString(_path);
|
||||
if (isAbsolute(normalizedPath)) return normalizedPath;
|
||||
|
||||
return join(
|
||||
..._path.split("/").map((segment) => {
|
||||
...normalizedPath.split("/").map((segment) => {
|
||||
let resolved = segment;
|
||||
try {
|
||||
resolved = app.getPath(resolved as any);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/",
|
||||
"lib": ["ESNext"],
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src", "global.d.ts"]
|
||||
"files": ["global.d.ts"],
|
||||
"include": ["src"],
|
||||
"exclude": ["__tests__"]
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ module.exports = {
|
||||
testBinaryPath:
|
||||
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
|
||||
build:
|
||||
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..",
|
||||
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug -PreactNativeArchitectures=arm64-v8a && cd ..",
|
||||
reversePorts: [8081]
|
||||
},
|
||||
"android.release": {
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
1
apps/mobile/.gitignore
vendored
1
apps/mobile/.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.yarnrc.yml
|
||||
tsconfig.tsbuildinfo
|
||||
artifacts/
|
||||
# OSX
|
||||
#
|
||||
|
||||
@@ -140,7 +140,7 @@ android {
|
||||
if (project.hasProperty("prBuildNumber")) {
|
||||
versionCode Integer.parseInt(prBuildNumber())
|
||||
} else {
|
||||
versionCode 3103
|
||||
versionCode 3113
|
||||
}
|
||||
versionName getNpmVersion()
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
|
||||
@@ -155,6 +155,15 @@
|
||||
<data android:scheme="notesnook" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Notesnook">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="nn" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
<activity
|
||||
android:name="com.facebook.react.devsupport.DevSettingsActivity"
|
||||
|
||||
@@ -33,7 +33,7 @@ public class NotePreviewWidget extends AppWidgetProvider {
|
||||
intent.putExtra(OpenNoteId, note.getId());
|
||||
intent.setAction(Intent.ACTION_VIEW);
|
||||
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
|
||||
intent.setData(Uri.parse("https://app.notesnook.com/open_note?id=" + note.getId()));
|
||||
intent.setData(Uri.parse("nn://note/" + note.getId()));
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
|
||||
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
|
||||
return;
|
||||
}
|
||||
|
||||
String uri = "https://app.notesnook.com/open_" + type + "?id=" + id;
|
||||
String uri = "nn://" + type + "/" + id;
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(uri));
|
||||
intent.setPackage(mContext.getPackageName());
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
- Bug fixes and improvements
|
||||
- Add option to clear note version history
|
||||
- Minor bug fixes and improvements
|
||||
|
||||
Thank you for using Notesnook!
|
||||
Thank you for using Notesnook!
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
generateCryptoKeyFallback
|
||||
} from "./encryption";
|
||||
import { MMKV } from "./mmkv";
|
||||
import OpenPGP from "react-native-fast-openpgp";
|
||||
import { strings } from "@notesnook/intl";
|
||||
|
||||
export class KV {
|
||||
storage: MMKVInstance;
|
||||
@@ -136,11 +138,41 @@ export const Storage: IStorage = {
|
||||
clear(): Promise<void> {
|
||||
return DefaultStorage.clear();
|
||||
},
|
||||
generateCryptoKeyPair() {
|
||||
throw new Error("Not implemented");
|
||||
async generatePGPKeyPair() {
|
||||
const keys = await OpenPGP.generate({
|
||||
name: "NN",
|
||||
email: "NN@NN.NN"
|
||||
});
|
||||
return { publicKey: keys.publicKey, privateKey: keys.privateKey };
|
||||
},
|
||||
decryptAsymmetric() {
|
||||
throw new Error("Not implemented");
|
||||
async validatePGPKeyPair(keys): Promise<{
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const dummyData = JSON.stringify({
|
||||
favorite: true,
|
||||
title: "Hello world"
|
||||
});
|
||||
const encrypted = await OpenPGP.encrypt(dummyData, keys.publicKey);
|
||||
const decrypted = await OpenPGP.decrypt(encrypted, keys.privateKey, "");
|
||||
|
||||
const isValid = decrypted === dummyData;
|
||||
|
||||
return {
|
||||
isValid,
|
||||
message: isValid ? "" : strings.invalidPgpKeyPair()
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("PGP key pair validation error:", e);
|
||||
return {
|
||||
isValid: false,
|
||||
message: strings.invalidPgpKeyPair()
|
||||
};
|
||||
}
|
||||
},
|
||||
async decryptPGPMessage(privateKeyArmored, encryptedMessage) {
|
||||
return await OpenPGP.decrypt(encryptedMessage, privateKeyArmored, "");
|
||||
},
|
||||
getAllKeys(): Promise<string[]> {
|
||||
return DefaultStorage.getAllKeys();
|
||||
|
||||
@@ -115,6 +115,10 @@ export const FileSizeResult = {
|
||||
Error: -1
|
||||
};
|
||||
|
||||
function getFileSizeFromHeaders(headers: Headers) {
|
||||
return headers.get("x-object-size") || headers.get("content-length");
|
||||
}
|
||||
|
||||
export async function getUploadedFileSize(hash: string, retry = 0) {
|
||||
try {
|
||||
const url = `${hosts.API_HOST}/s3?name=${hash}`;
|
||||
@@ -124,24 +128,21 @@ export async function getUploadedFileSize(hash: string, retry = 0) {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (
|
||||
!attachmentInfo.ok ||
|
||||
attachmentInfo.headers?.get("content-length") === null
|
||||
) {
|
||||
const fileSize = getFileSizeFromHeaders(attachmentInfo.headers);
|
||||
|
||||
if (!attachmentInfo.ok || fileSize === null) {
|
||||
if (retry < 3) {
|
||||
DatabaseLogger.log(`Retrying file size check: ${hash}, ${retry}`);
|
||||
return getUploadedFileSize(hash, retry + 1);
|
||||
}
|
||||
throw new Error(
|
||||
`File size check failed: ${hash}, ${
|
||||
attachmentInfo.status
|
||||
}, ${attachmentInfo.headers?.get("content-length")}`
|
||||
`File size check failed: ${hash}, ${attachmentInfo.status}, ${fileSize}`
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = parseInt(
|
||||
attachmentInfo.headers?.get("content-length") as string
|
||||
);
|
||||
console.log(attachmentInfo.headers);
|
||||
|
||||
const contentLength = parseInt(fileSize as string);
|
||||
return isNaN(contentLength) ? FileSizeResult.Empty : contentLength;
|
||||
} catch (e) {
|
||||
DatabaseLogger.error(e);
|
||||
@@ -161,10 +162,10 @@ export async function checkUpload(
|
||||
size === 0
|
||||
? `File size is 0.`
|
||||
: size === -1
|
||||
? `File verification check failed.`
|
||||
: expectedSize !== decryptedLength
|
||||
? `File size mismatch. Expected ${size} bytes but got ${decryptedLength} bytes.`
|
||||
: undefined;
|
||||
? `File verification check failed.`
|
||||
: expectedSize !== decryptedLength
|
||||
? `File size mismatch. Expected ${size} bytes but got ${decryptedLength} bytes.`
|
||||
: undefined;
|
||||
if (error) throw new Error(error);
|
||||
}
|
||||
|
||||
@@ -191,7 +192,8 @@ export async function checkAndCreateDir(path: string) {
|
||||
}
|
||||
|
||||
export const santizeUri = (uri: string) => {
|
||||
return Platform.OS === "ios" ? decodeURI(uri).replace("file:///", "/") : uri;
|
||||
const decoded = decodeURI(uri);
|
||||
return Platform.OS === "ios" ? decoded.replace("file:///", "/") : decoded;
|
||||
};
|
||||
|
||||
export function isSuccessStatusCode(statusCode: number) {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Attachment, Note, VirtualizedGrouping } from "@notesnook/core";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import Clipboard from "@react-native-clipboard/clipboard";
|
||||
import React, { RefObject, useEffect, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { db } from "../../common/database";
|
||||
@@ -59,6 +59,7 @@ import Paragraph from "../ui/typography/paragraph";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import Navigation from "../../services/navigation";
|
||||
import { createFormRef, validators } from "../ui/input/form-input";
|
||||
|
||||
const Actions = ({
|
||||
attachment,
|
||||
@@ -153,24 +154,48 @@ const Actions = ({
|
||||
{
|
||||
name: strings.rename(),
|
||||
onPress: () => {
|
||||
presentDialog({
|
||||
input: true,
|
||||
title: strings.renameFile(),
|
||||
defaultValue: attachment.filename,
|
||||
positivePress: async (value) => {
|
||||
if (value && value.length > 0) {
|
||||
await db.attachments.add({
|
||||
hash: attachment.hash,
|
||||
filename: value
|
||||
});
|
||||
setFilename(value);
|
||||
setAttachments();
|
||||
eSendEvent(eDBItemUpdate, attachment.id);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
positiveText: strings.rename()
|
||||
});
|
||||
close?.();
|
||||
setTimeout(() => {
|
||||
presentDialog({
|
||||
title: strings.renameFile(),
|
||||
form: {
|
||||
formRef: createFormRef({
|
||||
name: attachment.filename
|
||||
}),
|
||||
items: [
|
||||
{
|
||||
name: "name",
|
||||
defaultValue: attachment.filename,
|
||||
placeholder: strings.enterTitle(),
|
||||
ref: React.createRef<TextInput | null>(),
|
||||
validators: [validators.required(strings.nameIsRequired())]
|
||||
}
|
||||
],
|
||||
onFormSubmit: async (form) => {
|
||||
try {
|
||||
const value = form.getValue("name");
|
||||
await db.attachments.add({
|
||||
hash: attachment.hash,
|
||||
filename: value
|
||||
});
|
||||
setFilename(value);
|
||||
setAttachments();
|
||||
eSendEvent(eDBItemUpdate, attachment.id);
|
||||
ToastManager.show({
|
||||
message: `Attachment renamed to ${value}`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
form.setError("name", (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
positiveText: strings.rename()
|
||||
});
|
||||
}, 500);
|
||||
},
|
||||
icon: "form-textbox"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { db } from "../../common/database";
|
||||
@@ -26,42 +27,44 @@ import { eSendEvent, ToastManager } from "../../services/event-manager";
|
||||
import Navigation from "../../services/navigation";
|
||||
import { useUserStore } from "../../stores/use-user-store";
|
||||
import { eOpenRecoveryKeyDialog } from "../../utils/events";
|
||||
import { AppFontSize } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import { Dialog } from "../dialog";
|
||||
import AppIcon from "../ui/AppIcon";
|
||||
import { Button } from "../ui/button";
|
||||
import Input from "../ui/input";
|
||||
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
|
||||
import { Notice } from "../ui/notice";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import { TextInput } from "react-native-gesture-handler";
|
||||
|
||||
export const ChangePassword = () => {
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const password = useRef<string>(undefined);
|
||||
const { colors } = useThemeColors();
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
oldPassword: "",
|
||||
password: ""
|
||||
})
|
||||
);
|
||||
const oldPasswordInputRef = useRef<TextInput>(null);
|
||||
const oldPassword = useRef<string>(undefined);
|
||||
|
||||
const [error, setError] = useState(false);
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const user = useUserStore((state) => state.user);
|
||||
|
||||
const changePassword = async () => {
|
||||
setError(undefined);
|
||||
formRef.current.clearErrors();
|
||||
|
||||
if (!user?.isEmailConfirmed) {
|
||||
ToastManager.show({
|
||||
heading: strings.emailNotConfirmed(),
|
||||
message: strings.emailNotConfirmedDesc(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setError(strings.emailNotConfirmedDesc());
|
||||
return;
|
||||
}
|
||||
if (error || !oldPassword.current || !password.current) {
|
||||
ToastManager.show({
|
||||
heading: strings.allFieldsRequired(),
|
||||
message: strings.allFieldsRequiredDesc(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
if (!formRef.current.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = formRef.current.getValues();
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await BackupService.run(
|
||||
@@ -74,8 +77,8 @@ export const ChangePassword = () => {
|
||||
}
|
||||
|
||||
const passwordChanged = await db.user.changePassword(
|
||||
oldPassword.current,
|
||||
password.current
|
||||
values.oldPassword,
|
||||
values.password
|
||||
);
|
||||
|
||||
if (!passwordChanged) {
|
||||
@@ -91,15 +94,15 @@ export const ChangePassword = () => {
|
||||
Navigation.goBack();
|
||||
eSendEvent(eOpenRecoveryKeyDialog);
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
setLoading(false);
|
||||
ToastManager.show({
|
||||
heading: strings.passwordChangeFailed(),
|
||||
message: (e as Error).message,
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
|
||||
if (/old password/i.test(message)) {
|
||||
formRef.current.setError("oldPassword", message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -110,36 +113,61 @@ export const ChangePassword = () => {
|
||||
}}
|
||||
>
|
||||
<Dialog context="change-password-dialog" />
|
||||
<Input
|
||||
<FormInput
|
||||
name="oldPassword"
|
||||
formRef={formRef}
|
||||
fwdRef={oldPasswordInputRef}
|
||||
onChangeText={(value) => {
|
||||
oldPassword.current = value;
|
||||
}}
|
||||
loading={loading}
|
||||
validators={[validators.required(strings.currentPasswordRequired())]}
|
||||
returnKeyLabel="Next"
|
||||
returnKeyType="next"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder={strings.oldPassword()}
|
||||
placeholder={strings.currentPassword()}
|
||||
onSubmitEditing={() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
fwdRef={passwordInputRef}
|
||||
onChangeText={(value) => {
|
||||
password.current = value;
|
||||
}}
|
||||
onErrorCheck={(e) => setError(e)}
|
||||
loading={loading}
|
||||
validators={[validators.required(strings.passwordRequired())]}
|
||||
returnKeyLabel={strings.next()}
|
||||
returnKeyType="next"
|
||||
secureTextEntry
|
||||
validationType="password"
|
||||
autoComplete="password"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder={strings.newPassword()}
|
||||
onSubmitEditing={() => {
|
||||
changePassword();
|
||||
}}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Paragraph
|
||||
numberOfLines={4}
|
||||
onPress={() => {}}
|
||||
color={colors.error.accent}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
>
|
||||
<AppIcon
|
||||
color={colors.error.accent}
|
||||
name="alert-circle-outline"
|
||||
size={AppFontSize.sm - 1}
|
||||
/>{" "}
|
||||
{error}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
|
||||
<Notice text={strings.changePasswordNotice()} type="alert" />
|
||||
|
||||
<View style={{ height: 10 }} />
|
||||
|
||||
@@ -36,6 +36,7 @@ import { getElevationStyle } from "../../utils/elevation";
|
||||
import { AppFontSize, normalize } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import { hexToRGBA, RGB_Linear_Shade } from "../../utils/colors";
|
||||
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
|
||||
|
||||
interface FloatingButtonProps {
|
||||
onPress: () => void;
|
||||
@@ -47,6 +48,7 @@ interface FloatingButtonProps {
|
||||
position?: "left" | "right";
|
||||
size?: "small" | "large";
|
||||
style?: ViewStyle;
|
||||
hideOnKeyboard?: boolean;
|
||||
}
|
||||
|
||||
const FloatingButton = ({
|
||||
@@ -57,26 +59,28 @@ const FloatingButton = ({
|
||||
testID,
|
||||
position = "right",
|
||||
size = "large",
|
||||
style
|
||||
style,
|
||||
hideOnKeyboard = true
|
||||
}: FloatingButtonProps) => {
|
||||
const { colors } = useThemeColors();
|
||||
const deviceMode = useSettingStore((state) => state.deviceMode);
|
||||
const selectionMode = useSelectionStore((state) => state.selectionMode);
|
||||
const translate = useSharedValue(0);
|
||||
const keyboardHeight = useSharedValue(0);
|
||||
const route = useRoute();
|
||||
const insets = useGlobalSafeAreaInsets();
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateX: translate.value
|
||||
},
|
||||
{
|
||||
translateY: translate.value
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateX: hideOnKeyboard ? translate.value : 0
|
||||
},
|
||||
{
|
||||
translateY: hideOnKeyboard ? translate.value : 0
|
||||
}
|
||||
],
|
||||
bottom: hideOnKeyboard ? 15 : keyboardHeight.value + 15
|
||||
}));
|
||||
|
||||
const animate = useCallback(
|
||||
(toValue: number) => {
|
||||
@@ -97,12 +101,22 @@ const FloatingButton = ({
|
||||
editorState().keyboardState = false;
|
||||
if (deviceMode !== "mobile") return;
|
||||
animate(0);
|
||||
keyboardHeight.value = withTiming(0);
|
||||
};
|
||||
|
||||
const onKeyboardShow = () => {
|
||||
const onKeyboardShow = (e: any) => {
|
||||
editorState().keyboardState = true;
|
||||
if (deviceMode !== "mobile") return;
|
||||
animate(150);
|
||||
if (hideOnKeyboard) {
|
||||
animate(150);
|
||||
} else {
|
||||
keyboardHeight.value = withTiming(
|
||||
e.endCoordinates.height - insets.bottom,
|
||||
{
|
||||
duration: 250
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sub = [
|
||||
@@ -110,9 +124,9 @@ const FloatingButton = ({
|
||||
Keyboard.addListener("keyboardDidHide", onKeyboardHide)
|
||||
];
|
||||
return () => {
|
||||
sub.forEach((sub) => sub?.remove?.());
|
||||
sub.forEach((sub) => sub.remove());
|
||||
};
|
||||
}, [deviceMode, animate]);
|
||||
}, [deviceMode, animate, hideOnKeyboard, keyboardHeight, insets.bottom]);
|
||||
|
||||
return deviceMode !== "mobile" && !alwaysVisible ? null : (
|
||||
<Animated.View
|
||||
@@ -161,10 +175,10 @@ const FloatingButton = ({
|
||||
icon
|
||||
? icon
|
||||
: route.name === "Notebooks"
|
||||
? "notebook-plus"
|
||||
: route.name === "Trash"
|
||||
? "delete"
|
||||
: "plus"
|
||||
? "notebook-plus"
|
||||
: route.name === "Trash"
|
||||
? "delete"
|
||||
: "plus"
|
||||
}
|
||||
color={color || colors.primary.accent}
|
||||
size={size === "small" ? AppFontSize.xl : AppFontSize.xxxl}
|
||||
|
||||
@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import React from "react";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import { useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useWindowDimensions, View } from "react-native";
|
||||
import { defaultBorderRadius } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import DatePicker from "react-native-date-picker";
|
||||
@@ -32,7 +32,10 @@ export default function DatePickerComponent(props: {
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const dateRef = useRef<Date>(dayjs().add(1, "day").toDate());
|
||||
const dateRef = useRef<Date>(dayjs().add(1, "week").toDate());
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -46,6 +49,9 @@ export default function DatePickerComponent(props: {
|
||||
}}
|
||||
>
|
||||
<DatePicker
|
||||
style={{
|
||||
width: width * 0.8 - DefaultAppStyles.GAP * 2
|
||||
}}
|
||||
theme={isDark ? "dark" : "light"}
|
||||
mode="date"
|
||||
minimumDate={dayjs().add(1, "day").toDate()}
|
||||
|
||||
@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { ActivityIndicator, StyleSheet, View } from "react-native";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { notesnook } from "../../../e2e/test.ids";
|
||||
import { getColorLinearShade } from "../../utils/colors";
|
||||
@@ -79,6 +79,11 @@ const DialogButtons = ({
|
||||
/>
|
||||
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
|
||||
</View>
|
||||
) : loading ? (
|
||||
<ActivityIndicator
|
||||
size={AppFontSize.lg}
|
||||
color={colors.primary.accent}
|
||||
/>
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
@@ -105,7 +110,6 @@ const DialogButtons = ({
|
||||
style={{
|
||||
marginLeft: 10
|
||||
}}
|
||||
loading={loading}
|
||||
bold
|
||||
type={positiveType || "transparent"}
|
||||
title={positiveTitle}
|
||||
|
||||
@@ -67,15 +67,13 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
// Handle form submission if form is available
|
||||
if (dialogInfo?.form && formRef.current) {
|
||||
inputRef.current?.blur();
|
||||
setLoading(true);
|
||||
try {
|
||||
const isValid = await formRef.current.validate();
|
||||
if (!isValid) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogInfo.form.onFormSubmit) {
|
||||
setLoading(true);
|
||||
const result = await dialogInfo.form.onFormSubmit(formRef.current);
|
||||
if (result === false) {
|
||||
setLoading(false);
|
||||
@@ -93,7 +91,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
|
||||
let result = false;
|
||||
try {
|
||||
result = await dialogInfo.positivePress(
|
||||
values.current.inputValue || dialogInfo.defaultValue,
|
||||
values.current.inputValue,
|
||||
checked
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Button } from "../../ui/button";
|
||||
import { IconButton } from "../../ui/icon-button";
|
||||
import { Notice } from "../../ui/notice";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import SettingsService from "../../../services/settings";
|
||||
|
||||
export default function AttachImage({
|
||||
response,
|
||||
@@ -201,6 +202,17 @@ AttachImage.present = (response: ImageType[], context?: string) => {
|
||||
| undefined
|
||||
>((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
const imageCompressionSetting =
|
||||
SettingsService.getProperty("imageCompression");
|
||||
|
||||
if (imageCompressionSetting !== "ask-every-time") {
|
||||
resolve({
|
||||
compress: imageCompressionSetting === "enabled" ? true : false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
presentSheet({
|
||||
context: context,
|
||||
component: (ref, close, update) => (
|
||||
|
||||
@@ -51,7 +51,10 @@ import DialogButtons from "../../dialog/dialog-buttons";
|
||||
import DialogHeader from "../../dialog/dialog-header";
|
||||
import { Toast } from "../../toast";
|
||||
import { Button } from "../../ui/button";
|
||||
import Input from "../../ui/input";
|
||||
import FormInput, {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../ui/input/form-input";
|
||||
import Seperator from "../../ui/seperator";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import { strings } from "@notesnook/intl";
|
||||
@@ -63,15 +66,18 @@ import {
|
||||
VAULT_ERRORS
|
||||
} from "@notesnook/core";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import { useUserStore } from "../../../stores/use-user-store";
|
||||
import { AppFontSize } from "../../../utils/size";
|
||||
import { Pressable } from "../../ui/pressable";
|
||||
import AppIcon from "../../ui/AppIcon";
|
||||
|
||||
export const VaultDialog: React.FC = () => {
|
||||
const { colors } = useThemeColors();
|
||||
|
||||
// UI State
|
||||
const [visible, setVisible] = useState(false);
|
||||
const isUserLoggedIn = useUserStore((state) => !!state.user);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wrongPassword, setWrongPassword] = useState(false);
|
||||
const [passwordsDontMatch, setPasswordsDontMatch] = useState(false);
|
||||
const [deleteAll, setDeleteAll] = useState(false);
|
||||
const [biometricUnlock, setBiometricUnlock] = useState(false);
|
||||
const [isBiometryAvailable, setIsBiometryAvailable] = useState(false);
|
||||
@@ -100,15 +106,19 @@ export const VaultDialog: React.FC = () => {
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
// Form ref
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
newPassword: ""
|
||||
})
|
||||
);
|
||||
|
||||
// Input refs
|
||||
const passInputRef = useRef<TextInput>(null);
|
||||
const confirmPassRef = useRef<TextInput>(null);
|
||||
const changePassInputRef = useRef<TextInput>(null);
|
||||
|
||||
// Password refs
|
||||
const passwordRef = useRef<string | null>(null);
|
||||
const confirmPasswordRef = useRef<string | null>(null);
|
||||
const newPasswordRef = useRef<string | null>(null);
|
||||
const newPassInputRef = useRef<TextInput>(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (loading) {
|
||||
@@ -123,10 +133,11 @@ export const VaultDialog: React.FC = () => {
|
||||
|
||||
Navigation.queueRoutesForUpdate();
|
||||
|
||||
// Reset password refs
|
||||
passwordRef.current = null;
|
||||
confirmPasswordRef.current = null;
|
||||
newPasswordRef.current = null;
|
||||
// Reset form values and errors
|
||||
formRef.current.setValue("password", "");
|
||||
formRef.current.setValue("confirmPassword", "");
|
||||
formRef.current.setValue("newPassword", "");
|
||||
formRef.current.clearErrors();
|
||||
|
||||
// Reset refs
|
||||
requestTypeRef.current = null;
|
||||
@@ -144,8 +155,6 @@ export const VaultDialog: React.FC = () => {
|
||||
// Reset UI state
|
||||
setVisible(false);
|
||||
setLoading(false);
|
||||
setWrongPassword(false);
|
||||
setPasswordsDontMatch(false);
|
||||
setDeleteAll(false);
|
||||
setBiometricUnlock(false);
|
||||
setIsBiometryAvailable(false);
|
||||
@@ -155,9 +164,10 @@ export const VaultDialog: React.FC = () => {
|
||||
const deleteVault = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { password } = formRef.current.getValues();
|
||||
let verified = true;
|
||||
if (await db.user.getUser()) {
|
||||
verified = await db.user.verifyPassword(passwordRef.current || "");
|
||||
verified = await db.user.verifyPassword(password);
|
||||
}
|
||||
if (verified) {
|
||||
let noteIds: string[] = [];
|
||||
@@ -175,6 +185,7 @@ export const VaultDialog: React.FC = () => {
|
||||
noteIds = relations.map((item) => item.toId);
|
||||
}
|
||||
await db.vault.delete(deleteAll);
|
||||
await BiometricService.resetCredentials();
|
||||
|
||||
if (deleteAll) {
|
||||
noteIds.forEach((id) => {
|
||||
@@ -190,16 +201,15 @@ export const VaultDialog: React.FC = () => {
|
||||
}
|
||||
eSendEvent("vaultUpdated");
|
||||
setLoading(false);
|
||||
setTimeout(() => {
|
||||
close();
|
||||
}, 100);
|
||||
close();
|
||||
ToastManager.show({
|
||||
message: strings.vaultDeleted(),
|
||||
type: "success",
|
||||
context: "global"
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
formRef.current.setError("password", strings.passwordIncorrect());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -209,11 +219,12 @@ export const VaultDialog: React.FC = () => {
|
||||
const clearVault = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { password } = formRef.current.getValues();
|
||||
const vault = await db.vaults.default();
|
||||
const relations = await db.relations.from(vault!, "note").get();
|
||||
const noteIds = relations.map((item) => item.toId);
|
||||
|
||||
await db.vault.clear(passwordRef.current || "");
|
||||
await db.vault.clear(password);
|
||||
|
||||
noteIds.forEach((id) => {
|
||||
eSendEvent(
|
||||
@@ -233,11 +244,7 @@ export const VaultDialog: React.FC = () => {
|
||||
type: "success"
|
||||
});
|
||||
} catch (e) {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
formRef.current.setError("password", strings.passwordIncorrect());
|
||||
}
|
||||
setLoading(false);
|
||||
}, [close]);
|
||||
@@ -257,12 +264,7 @@ export const VaultDialog: React.FC = () => {
|
||||
});
|
||||
close();
|
||||
} catch (e) {
|
||||
close();
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
formRef.current.setError("password", strings.passwordIncorrect());
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
@@ -270,24 +272,14 @@ export const VaultDialog: React.FC = () => {
|
||||
);
|
||||
|
||||
const takeErrorAction = useCallback(() => {
|
||||
setWrongPassword(true);
|
||||
formRef.current.setError("password", strings.passwordIncorrect());
|
||||
setVisible(true);
|
||||
setTimeout(() => {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const lockNote = useCallback(async () => {
|
||||
if (!passwordRef.current || passwordRef.current.trim() === "") {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
const { password } = formRef.current.getValues();
|
||||
if (!password || password.trim() === "") {
|
||||
formRef.current.setError("password", strings.passwordIncorrect());
|
||||
return;
|
||||
} else {
|
||||
await db.vault.add(noteRef.current!.id);
|
||||
@@ -305,8 +297,9 @@ export const VaultDialog: React.FC = () => {
|
||||
}, [close]);
|
||||
|
||||
const permanantUnlock = useCallback(() => {
|
||||
const { password } = formRef.current.getValues();
|
||||
db.vault
|
||||
.remove(noteRef.current!.id, passwordRef.current || "")
|
||||
.remove(noteRef.current!.id, password)
|
||||
.then(async () => {
|
||||
ToastManager.show({
|
||||
heading: strings.noteUnlocked(),
|
||||
@@ -315,7 +308,7 @@ export const VaultDialog: React.FC = () => {
|
||||
});
|
||||
eSendEvent(eUpdateNoteInEditor, noteRef.current, true);
|
||||
if (biometricUnlock && !isBiometryEnrolled) {
|
||||
await enrollFingerprint(passwordRef.current || "");
|
||||
await enrollFingerprint(password);
|
||||
}
|
||||
close();
|
||||
})
|
||||
@@ -375,8 +368,9 @@ export const VaultDialog: React.FC = () => {
|
||||
);
|
||||
|
||||
const deleteNote = useCallback(async () => {
|
||||
const { password } = formRef.current.getValues();
|
||||
try {
|
||||
await db.vault.remove(noteRef.current!.id, passwordRef.current || "");
|
||||
await db.vault.remove(noteRef.current!.id, password);
|
||||
await deleteItems("note", [noteRef.current!.id]);
|
||||
close();
|
||||
} catch (e) {
|
||||
@@ -385,16 +379,14 @@ export const VaultDialog: React.FC = () => {
|
||||
}, [close, takeErrorAction]);
|
||||
|
||||
const openNote = useCallback(async () => {
|
||||
const { password } = formRef.current.getValues();
|
||||
try {
|
||||
if (!passwordRef.current) throw new Error("Invalid password");
|
||||
if (!password) throw new Error("Invalid password");
|
||||
|
||||
const note = await db.vault.open(
|
||||
noteRef.current!.id,
|
||||
passwordRef.current
|
||||
);
|
||||
const note = await db.vault.open(noteRef.current!.id, password);
|
||||
if (!note) throw new Error("Failed to unlock note.");
|
||||
if (biometricUnlock && !isBiometryEnrolled) {
|
||||
await enrollFingerprint(passwordRef.current || "");
|
||||
await enrollFingerprint(password);
|
||||
}
|
||||
|
||||
const requestType = requestTypeRef.current;
|
||||
@@ -411,7 +403,6 @@ export const VaultDialog: React.FC = () => {
|
||||
requestType === VaultRequestType.CustomAction &&
|
||||
onUnlockRef.current
|
||||
) {
|
||||
const password = passwordRef.current;
|
||||
const unlock = onUnlockRef.current;
|
||||
close();
|
||||
await sleep(500);
|
||||
@@ -433,12 +424,9 @@ export const VaultDialog: React.FC = () => {
|
||||
]);
|
||||
|
||||
const unlockNote = useCallback(async () => {
|
||||
if (!passwordRef.current || passwordRef.current.trim() === "") {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
const { password } = formRef.current.getValues();
|
||||
if (!password || password.trim() === "") {
|
||||
formRef.current.setError("password", strings.passwordIncorrect());
|
||||
return;
|
||||
}
|
||||
if (requestTypeRef.current === VaultRequestType.PermanentUnlock) {
|
||||
@@ -449,10 +437,11 @@ export const VaultDialog: React.FC = () => {
|
||||
}, [permanantUnlock, openNote]);
|
||||
|
||||
const createVault = useCallback(async () => {
|
||||
await db.vault.create(passwordRef.current || "");
|
||||
const { password } = formRef.current.getValues();
|
||||
await db.vault.create(password);
|
||||
|
||||
if (biometricUnlock) {
|
||||
await enrollFingerprint(passwordRef.current || "");
|
||||
await enrollFingerprint(password);
|
||||
}
|
||||
if (noteRef.current?.id) {
|
||||
await db.vault.add(noteRef.current.id);
|
||||
@@ -504,36 +493,30 @@ export const VaultDialog: React.FC = () => {
|
||||
|
||||
if (loading) return;
|
||||
|
||||
if (!passwordRef.current) {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordNotEntered(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
if (!formRef.current.validate()) return;
|
||||
|
||||
const { password, newPassword } = formRef.current.getValues();
|
||||
|
||||
if (requestType === VaultRequestType.DeleteVault && !isUserLoggedIn) {
|
||||
setLoading(true);
|
||||
await deleteVault();
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestType === VaultRequestType.CreateVault) {
|
||||
if (passwordRef.current !== confirmPasswordRef.current) {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordNotMatched(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setPasswordsDontMatch(true);
|
||||
return;
|
||||
}
|
||||
|
||||
createVault();
|
||||
} else if (requestType === VaultRequestType.ChangePassword) {
|
||||
setLoading(true);
|
||||
|
||||
db.vault
|
||||
.changePassword(passwordRef.current, newPasswordRef.current || "")
|
||||
.then(() => {
|
||||
.changePassword(password, newPassword)
|
||||
.then(async () => {
|
||||
setLoading(false);
|
||||
if (biometricUnlock) {
|
||||
enrollFingerprint(newPasswordRef.current || "");
|
||||
enrollFingerprint(newPassword);
|
||||
} else {
|
||||
await BiometricService.resetCredentials();
|
||||
}
|
||||
ToastManager.show({
|
||||
heading: strings.passwordUpdated(),
|
||||
@@ -545,37 +528,23 @@ export const VaultDialog: React.FC = () => {
|
||||
.catch((e) => {
|
||||
setLoading(false);
|
||||
if (e.message === VAULT_ERRORS.wrongPassword) {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
formRef.current.setError("password", strings.passwordIncorrect());
|
||||
} else {
|
||||
ToastManager.error(e);
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
} else if (requestType === VaultRequestType.LockNote) {
|
||||
if (!passwordRef.current || passwordRef.current.trim() === "") {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setWrongPassword(true);
|
||||
return;
|
||||
}
|
||||
db.vault
|
||||
.unlock(passwordRef.current)
|
||||
.unlock(password)
|
||||
.then(async (unlocked) => {
|
||||
if (unlocked) {
|
||||
setWrongPassword(false);
|
||||
await lockNote();
|
||||
} else {
|
||||
takeErrorAction();
|
||||
formRef.current.setError("password", strings.passwordIncorrect());
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
takeErrorAction();
|
||||
formRef.current.setError("password", strings.passwordIncorrect());
|
||||
});
|
||||
} else if (
|
||||
requestType === VaultRequestType.UnlockNote ||
|
||||
@@ -586,22 +555,13 @@ export const VaultDialog: React.FC = () => {
|
||||
requestType === VaultRequestType.DeleteNote ||
|
||||
requestType === VaultRequestType.CustomAction
|
||||
) {
|
||||
if (!passwordRef.current || passwordRef.current.trim() === "") {
|
||||
ToastManager.show({
|
||||
heading: strings.passwordIncorrect(),
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
setWrongPassword(true);
|
||||
return;
|
||||
}
|
||||
if (noteLockedRef.current) {
|
||||
await unlockNote();
|
||||
} else {
|
||||
console.log("Error: Note should be locked for this operation");
|
||||
}
|
||||
} else if (requestType === VaultRequestType.EnableFingerprint) {
|
||||
enrollFingerprint(passwordRef.current);
|
||||
enrollFingerprint(password);
|
||||
} else if (requestType === VaultRequestType.ClearVault) {
|
||||
await clearVault();
|
||||
} else if (requestType === VaultRequestType.DeleteVault) {
|
||||
@@ -616,7 +576,6 @@ export const VaultDialog: React.FC = () => {
|
||||
enrollFingerprint,
|
||||
unlockNote,
|
||||
lockNote,
|
||||
takeErrorAction,
|
||||
clearVault,
|
||||
deleteVault
|
||||
]);
|
||||
@@ -632,7 +591,7 @@ export const VaultDialog: React.FC = () => {
|
||||
if (!credentials) throw new Error("Failed to get user credentials");
|
||||
|
||||
if (credentials?.password) {
|
||||
passwordRef.current = credentials.password;
|
||||
formRef.current.setValue("password", credentials.password);
|
||||
onPress();
|
||||
} else {
|
||||
eSendEvent(eCloseActionSheet);
|
||||
@@ -679,24 +638,28 @@ export const VaultDialog: React.FC = () => {
|
||||
setIsBiometryAvailable(available);
|
||||
setIsBiometryEnrolled(fingerprint);
|
||||
setBiometricUnlock(fingerprint);
|
||||
setWrongPassword(false);
|
||||
setPasswordsDontMatch(false);
|
||||
setDeleteAll(false);
|
||||
setLoading(false);
|
||||
|
||||
// Auto-unlock with fingerprint if applicable
|
||||
const canAutoUnlock =
|
||||
fingerprint &&
|
||||
available &&
|
||||
data.requestType !== VaultRequestType.EnableFingerprint &&
|
||||
data.requestType !== VaultRequestType.RevokeFingerprint &&
|
||||
data.requestType !== VaultRequestType.ChangePassword &&
|
||||
data.requestType !== VaultRequestType.ClearVault &&
|
||||
data.requestType !== VaultRequestType.DeleteVault &&
|
||||
data.requestType !== VaultRequestType.CustomAction &&
|
||||
data.requestType !== VaultRequestType.PermanentUnlock;
|
||||
data.requestType !== VaultRequestType.PermanentUnlock &&
|
||||
data.requestType !== VaultRequestType.CreateVault;
|
||||
|
||||
if (canAutoUnlock) {
|
||||
await onPressFingerprintAuth(data.title, data.description);
|
||||
if (canAutoUnlock && available) {
|
||||
try {
|
||||
await onPressFingerprintAuth(data.title, data.description);
|
||||
} catch (e) {
|
||||
setVisible(true);
|
||||
}
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
@@ -758,7 +721,6 @@ export const VaultDialog: React.FC = () => {
|
||||
icon="shield"
|
||||
padding={12}
|
||||
/>
|
||||
<Seperator half />
|
||||
|
||||
<View
|
||||
style={{
|
||||
@@ -767,19 +729,25 @@ export const VaultDialog: React.FC = () => {
|
||||
>
|
||||
{(isChangePassword ||
|
||||
isClearVault ||
|
||||
!isCreateVault ||
|
||||
isDeleteVault ||
|
||||
requestType === VaultRequestType.LockNote ||
|
||||
requestType === VaultRequestType.UnlockNote ||
|
||||
requestType === VaultRequestType.PermanentUnlock ||
|
||||
isGoToEditor ||
|
||||
isShareNote ||
|
||||
isDeleteNote ||
|
||||
isEnableFingerprint ||
|
||||
(isDeleteVault && isUserLoggedIn) ||
|
||||
isCustomAction) &&
|
||||
!isRevokeFingerprint ? (
|
||||
<>
|
||||
<Input
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
fwdRef={passInputRef}
|
||||
editable={!loading}
|
||||
autoCapitalize="none"
|
||||
testID={notesnook.ids.dialogs.vault.pwd}
|
||||
onChangeText={(value) => {
|
||||
passwordRef.current = value;
|
||||
}}
|
||||
autoComplete="password"
|
||||
marginBottom={
|
||||
!biometricUnlock ||
|
||||
!isBiometryEnrolled ||
|
||||
@@ -789,14 +757,13 @@ export const VaultDialog: React.FC = () => {
|
||||
? 0
|
||||
: 10
|
||||
}
|
||||
onSubmit={() => {
|
||||
onSubmitEditing={() => {
|
||||
if (isChangePassword) {
|
||||
confirmPassRef.current?.focus();
|
||||
newPassInputRef.current?.focus();
|
||||
} else {
|
||||
onPress();
|
||||
}
|
||||
}}
|
||||
autoComplete="password"
|
||||
returnKeyLabel={
|
||||
isChangePassword ? strings.next() : titleRef.current
|
||||
}
|
||||
@@ -807,6 +774,7 @@ export const VaultDialog: React.FC = () => {
|
||||
? strings.currentPassword()
|
||||
: strings.password()
|
||||
}
|
||||
validators={[validators.required(strings.passwordRequired())]}
|
||||
/>
|
||||
|
||||
{!biometricUnlock ||
|
||||
@@ -830,89 +798,98 @@ export const VaultDialog: React.FC = () => {
|
||||
) : null}
|
||||
|
||||
{isDeleteVault && (
|
||||
<Button
|
||||
<Pressable
|
||||
onPress={() => setDeleteAll(!deleteAll)}
|
||||
icon={
|
||||
deleteAll
|
||||
? "check-circle-outline"
|
||||
: "checkbox-blank-circle-outline"
|
||||
}
|
||||
style={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
paddingVertical: 0,
|
||||
flexDirection: "row",
|
||||
gap: DefaultAppStyles.GAP_SMALL,
|
||||
marginTop: isUserLoggedIn
|
||||
? DefaultAppStyles.GAP_VERTICAL_SMALL
|
||||
: 0,
|
||||
justifyContent: "flex-start"
|
||||
}}
|
||||
width="100%"
|
||||
title={strings.deleteAllNotes()}
|
||||
type="errorShade"
|
||||
/>
|
||||
>
|
||||
<AppIcon
|
||||
name={
|
||||
deleteAll
|
||||
? "check-circle-outline"
|
||||
: "checkbox-blank-circle-outline"
|
||||
}
|
||||
color={colors.error.accent}
|
||||
size={AppFontSize.md}
|
||||
/>
|
||||
|
||||
<Paragraph color={colors.error.accent} size={AppFontSize.sm}>
|
||||
{strings.deleteAllNotes()}
|
||||
</Paragraph>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{isChangePassword ? (
|
||||
<>
|
||||
<Seperator half />
|
||||
<Input
|
||||
fwdRef={confirmPassRef}
|
||||
<FormInput
|
||||
name="newPassword"
|
||||
formRef={formRef}
|
||||
fwdRef={newPassInputRef}
|
||||
editable={!loading}
|
||||
testID={notesnook.ids.dialogs.vault.changePwd}
|
||||
autoCapitalize="none"
|
||||
onChangeText={(value) => {
|
||||
newPasswordRef.current = value;
|
||||
}}
|
||||
autoComplete="password"
|
||||
onSubmit={() => {
|
||||
onSubmitEditing={() => {
|
||||
onPress();
|
||||
}}
|
||||
returnKeyLabel="Change"
|
||||
returnKeyType="done"
|
||||
secureTextEntry
|
||||
placeholder={strings.newPassword()}
|
||||
validators={[validators.required(strings.passwordRequired())]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isCreateVault ? (
|
||||
<View>
|
||||
<Input
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
fwdRef={passInputRef}
|
||||
autoCapitalize="none"
|
||||
testID={notesnook.ids.dialogs.vault.pwd}
|
||||
onChangeText={(value) => {
|
||||
passwordRef.current = value;
|
||||
}}
|
||||
autoComplete="password"
|
||||
returnKeyLabel={strings.next()}
|
||||
returnKeyType="next"
|
||||
secureTextEntry
|
||||
onSubmit={() => {
|
||||
onSubmitEditing={() => {
|
||||
confirmPassRef.current?.focus();
|
||||
}}
|
||||
placeholder={strings.password()}
|
||||
validators={[validators.required(strings.passwordRequired())]}
|
||||
/>
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="confirmPassword"
|
||||
formRef={formRef}
|
||||
fwdRef={confirmPassRef}
|
||||
autoCapitalize="none"
|
||||
testID={notesnook.ids.dialogs.vault.pwdAlt}
|
||||
secureTextEntry
|
||||
validationType="confirmPassword"
|
||||
customValidator={() => passwordRef.current || ""}
|
||||
errorMessage="Passwords do not match."
|
||||
onErrorCheck={() => null}
|
||||
marginBottom={0}
|
||||
autoComplete="password"
|
||||
returnKeyLabel="Create"
|
||||
returnKeyType="done"
|
||||
onChangeText={(value) => {
|
||||
confirmPasswordRef.current = value;
|
||||
if (value !== passwordRef.current) {
|
||||
setPasswordsDontMatch(true);
|
||||
} else {
|
||||
setPasswordsDontMatch(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={() => {
|
||||
marginBottom={0}
|
||||
onSubmitEditing={() => {
|
||||
onPress();
|
||||
}}
|
||||
placeholder={strings.confirmPassword()}
|
||||
validators={[
|
||||
validators.required(strings.confirmPasswordRequired()),
|
||||
validators.matchField(
|
||||
"password",
|
||||
strings.passwordNotMatched()
|
||||
)
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -39,6 +39,7 @@ import Share from "react-native-share";
|
||||
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
|
||||
import { useSettingStore } from "../../stores/use-setting-store";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
import FileViewer from "react-native-file-viewer";
|
||||
|
||||
const ImagePreview = () => {
|
||||
const { colors } = useThemeColors("dialog");
|
||||
@@ -87,9 +88,8 @@ const ImagePreview = () => {
|
||||
return;
|
||||
}
|
||||
const attachment = await db.attachments.attachment(hash);
|
||||
const path = `${cacheDir}/${
|
||||
"NN_" + (await getFileNameWithExtension(hash, attachment?.mimeType))
|
||||
}`;
|
||||
const path = `${cacheDir}/${"NN_" + (await getFileNameWithExtension(hash, attachment?.mimeType))
|
||||
}`;
|
||||
await RNFetchBlob.fs.mv(`${cacheDir}/${uri}`, path).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
@@ -136,17 +136,23 @@ const ImagePreview = () => {
|
||||
borderWidth: 0
|
||||
}}
|
||||
onPress={async () => {
|
||||
useSettingStore
|
||||
.getState()
|
||||
.setAppDidEnterBackgroundForAction(true);
|
||||
await Share.open({
|
||||
url: image
|
||||
}).catch(() => {
|
||||
/* empty */
|
||||
});
|
||||
useSettingStore
|
||||
.getState()
|
||||
.setAppDidEnterBackgroundForAction(false);
|
||||
if (!image) return;
|
||||
|
||||
try {
|
||||
if (Platform.OS === "android") {
|
||||
await FileViewer.open(image.replace("file://", ""), {
|
||||
showOpenWithDialog: true,
|
||||
showAppsSuggestions: true,
|
||||
shareFile: true
|
||||
} as any);
|
||||
} else {
|
||||
await Share.open({
|
||||
url: image
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
|
||||
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import {
|
||||
GroupHeader,
|
||||
GroupingByIdKey,
|
||||
GroupingKey,
|
||||
GroupOptions,
|
||||
ItemType
|
||||
@@ -46,6 +47,8 @@ type SectionHeaderProps = {
|
||||
screen?: RouteName;
|
||||
groupOptions: GroupOptions;
|
||||
group: GroupingKey;
|
||||
groupId?: string;
|
||||
type?: GroupingByIdKey;
|
||||
onOpenJumpToDialog: () => void;
|
||||
itemCount?: number;
|
||||
};
|
||||
@@ -62,7 +65,9 @@ export const SectionHeader = React.memo<
|
||||
groupOptions,
|
||||
group,
|
||||
onOpenJumpToDialog,
|
||||
itemCount
|
||||
itemCount,
|
||||
groupId,
|
||||
type
|
||||
}: SectionHeaderProps) {
|
||||
const { colors } = useThemeColors();
|
||||
const isCompactModeEnabled = useIsCompactModeEnabled(
|
||||
@@ -143,8 +148,10 @@ export const SectionHeader = React.memo<
|
||||
component: (
|
||||
<Sort
|
||||
screen={screen}
|
||||
type={dataType}
|
||||
dataType={dataType}
|
||||
type={type}
|
||||
group={group}
|
||||
groupId={groupId}
|
||||
hideGroupOptions={
|
||||
screen === "Reminders" || screen === "Search"
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import { TimeSince } from "../../ui/time-since";
|
||||
import Heading from "../../ui/typography/heading";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import dayjs from "dayjs";
|
||||
import { ExpiryDate } from "../../ui/expiry-date";
|
||||
|
||||
type NoteItemProps = {
|
||||
item: Note | BaseTrashItem<Note>;
|
||||
@@ -266,6 +267,21 @@ const NoteItem = ({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{item.expiryDate?.value ? (
|
||||
<ExpiryDate
|
||||
note={item as Note}
|
||||
color={color?.colorCode}
|
||||
textStyle={{ fontSize: AppFontSize.xxs }}
|
||||
short
|
||||
iconSize={AppFontSize.xxs}
|
||||
style={{
|
||||
justifyContent: "flex-start",
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL / 2,
|
||||
alignSelf: "flex-start"
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{notebooks?.items
|
||||
?.filter(
|
||||
(item) =>
|
||||
|
||||
@@ -17,7 +17,12 @@ 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 { GroupingKey, Item, VirtualizedGrouping } from "@notesnook/core";
|
||||
import {
|
||||
GroupingByIdKey,
|
||||
GroupingKey,
|
||||
Item,
|
||||
VirtualizedGrouping
|
||||
} from "@notesnook/core";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import { LegendList, LegendListRenderItemProps } from "@legendapp/list";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
@@ -55,6 +60,7 @@ type ListProps = {
|
||||
placeholder?: PlaceholderData;
|
||||
groupType: GroupingKey;
|
||||
id?: string;
|
||||
type?: GroupingByIdKey;
|
||||
};
|
||||
|
||||
const onMomentumScrollEnd = () => {
|
||||
@@ -74,7 +80,7 @@ export default function List(props: ListProps) {
|
||||
props.dataType === "notebook" ||
|
||||
notebooksListMode === "compact";
|
||||
|
||||
const groupOptions = useGroupOptions(props.groupType);
|
||||
const groupOptions = useGroupOptions(props.groupType, props.id, props.type);
|
||||
|
||||
const _onRefresh = async () => {
|
||||
Sync.run("global", false, "full", () => {
|
||||
@@ -96,23 +102,27 @@ export default function List(props: ListProps) {
|
||||
index={itemProps.index}
|
||||
isSheet={props.isRenderedInActionSheet || false}
|
||||
items={props.data}
|
||||
groupId={props.id}
|
||||
groupOptions={groupOptions}
|
||||
group={props.groupType as GroupingKey}
|
||||
renderedInRoute={props.renderedInRoute}
|
||||
customAccentColor={props.customAccentColor}
|
||||
dataType={props.dataType}
|
||||
type={props.type}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
groupOptions,
|
||||
props.groupType,
|
||||
props.customAccentColor,
|
||||
props.data,
|
||||
props.dataType,
|
||||
props.isRenderedInActionSheet,
|
||||
props.renderedInRoute
|
||||
props.data,
|
||||
props.id,
|
||||
props.groupType,
|
||||
props.renderedInRoute,
|
||||
props.customAccentColor,
|
||||
props.dataType,
|
||||
groupOptions,
|
||||
props.type
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Color,
|
||||
GroupHeader,
|
||||
GroupOptions,
|
||||
GroupingByIdKey,
|
||||
GroupingKey,
|
||||
HighlightedResult,
|
||||
Item,
|
||||
@@ -40,7 +41,7 @@ import {
|
||||
} from "@notesnook/core";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { db } from "../../common/database";
|
||||
import { getGroupOptions } from "../../hooks/use-group-options";
|
||||
import { useIsCompactModeEnabled } from "../../hooks/use-is-compact-mode-enabled";
|
||||
import { eSendEvent } from "../../services/event-manager";
|
||||
import { RouteName } from "../../stores/use-navigation-store";
|
||||
@@ -49,8 +50,8 @@ import { SectionHeader } from "../list-items/headers/section-header";
|
||||
import { NoteWrapper } from "../list-items/note/wrapper";
|
||||
import { NotebookWrapper } from "../list-items/notebook/wrapper";
|
||||
import ReminderItem from "../list-items/reminder";
|
||||
import TagItem from "../list-items/tag";
|
||||
import { SearchResult } from "../list-items/search-result";
|
||||
import TagItem from "../list-items/tag";
|
||||
|
||||
type ListItemWrapperProps<TItem = Item> = {
|
||||
group: GroupingKey;
|
||||
@@ -62,6 +63,8 @@ type ListItemWrapperProps<TItem = Item> = {
|
||||
dataType: string;
|
||||
scrollRef: any;
|
||||
groupOptions: GroupOptions;
|
||||
groupId?: string;
|
||||
type?: GroupingByIdKey;
|
||||
};
|
||||
|
||||
export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
@@ -183,6 +186,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
index={index}
|
||||
dataType={item.type}
|
||||
group={group}
|
||||
groupId={props.groupId}
|
||||
type={props.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
@@ -220,6 +225,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
index={index}
|
||||
dataType={item.type}
|
||||
group={group}
|
||||
groupId={props.groupId}
|
||||
type={props.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
@@ -250,6 +257,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
group={group}
|
||||
dataType={item.type}
|
||||
color={props.customAccentColor}
|
||||
type={props.type}
|
||||
groupId={props.groupId}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
eSendEvent(eOpenJumpToDialog, {
|
||||
@@ -276,6 +285,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
index={index}
|
||||
group={group}
|
||||
dataType={item.type}
|
||||
groupId={props.groupId}
|
||||
type={props.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
onOpenJumpToDialog={() => {
|
||||
@@ -303,6 +314,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
index={index}
|
||||
group={group}
|
||||
dataType={item.type}
|
||||
groupId={props.groupId}
|
||||
type={props.type}
|
||||
color={props.customAccentColor}
|
||||
groupOptions={groupOptions}
|
||||
itemCount={items?.placeholders.length}
|
||||
@@ -322,11 +335,16 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function getDate(item: Notebook | Note, groupType?: GroupingKey): number {
|
||||
function getDate(
|
||||
item: Notebook | Note,
|
||||
groupType?: GroupingKey,
|
||||
id?: string,
|
||||
type?: GroupingByIdKey
|
||||
): number {
|
||||
return (
|
||||
getSortValue(
|
||||
groupType
|
||||
? db.settings.getGroupOptions(groupType)
|
||||
? getGroupOptions(groupType, id, type)
|
||||
: {
|
||||
sortBy: "dateEdited",
|
||||
sortDirection: "desc"
|
||||
|
||||
@@ -72,6 +72,7 @@ const MergeConflicts = () => {
|
||||
const insets = useGlobalSafeAreaInsets();
|
||||
const content = useRef<UnencryptedContentItem>(null);
|
||||
const { height } = useSettingStore((state) => state.dimensions);
|
||||
const keepScreenOn = useSettingStore((state) => state.settings.keepScreenOn);
|
||||
|
||||
const applyChanges = async () => {
|
||||
const contentToSave = selectedContent;
|
||||
@@ -155,9 +156,9 @@ const MergeConflicts = () => {
|
||||
...item.content,
|
||||
conflicted: currentContent?.conflicted
|
||||
? await db.vault.decryptContent(
|
||||
currentContent?.conflicted as EncryptedContentItem,
|
||||
password
|
||||
)
|
||||
currentContent?.conflicted as EncryptedContentItem,
|
||||
password
|
||||
)
|
||||
: undefined
|
||||
} as UnencryptedContentItem;
|
||||
|
||||
@@ -182,9 +183,9 @@ const MergeConflicts = () => {
|
||||
...item.content,
|
||||
conflicted: currentContent?.conflicted
|
||||
? await db.vault.decryptContent(
|
||||
currentContent?.conflicted as EncryptedContentItem,
|
||||
password
|
||||
)
|
||||
currentContent?.conflicted as EncryptedContentItem,
|
||||
password
|
||||
)
|
||||
: undefined
|
||||
} as UnencryptedContentItem;
|
||||
|
||||
@@ -385,7 +386,7 @@ const MergeConflicts = () => {
|
||||
paddingTop: insets.top
|
||||
}}
|
||||
>
|
||||
<KeepAwake />
|
||||
{keepScreenOn && <KeepAwake />}
|
||||
{dialogVisible && (
|
||||
<BaseDialog visible={true}>
|
||||
<DialogContainer>
|
||||
|
||||
@@ -29,7 +29,7 @@ import { ActionSheetRef, ScrollView } from "react-native-actions-sheet";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { db } from "../../common/database";
|
||||
import { useDBItem } from "../../hooks/use-db-item";
|
||||
import { presentSheet } from "../../services/event-manager";
|
||||
import { presentSheet, ToastManager } from "../../services/event-manager";
|
||||
import { openLinkInBrowser } from "../../utils/functions";
|
||||
import { AppFontSize } from "../../utils/size";
|
||||
import { DefaultAppStyles } from "../../utils/styles";
|
||||
@@ -39,15 +39,19 @@ import { Pressable } from "../ui/pressable";
|
||||
import Seperator from "../ui/seperator";
|
||||
import Paragraph from "../ui/typography/paragraph";
|
||||
import NotePreview from "./preview";
|
||||
import { presentDialog } from "../dialog/functions";
|
||||
import { Dialog } from "../dialog";
|
||||
|
||||
const HistoryItem = ({
|
||||
index,
|
||||
items,
|
||||
note
|
||||
note,
|
||||
refresh
|
||||
}: {
|
||||
index: number;
|
||||
items?: VirtualizedGrouping<HistorySession>;
|
||||
note: Note;
|
||||
refresh: () => void;
|
||||
}) => {
|
||||
const [item] = useDBItem(index, "noteHistory", items);
|
||||
const { colors } = useThemeColors();
|
||||
@@ -75,12 +79,13 @@ const HistoryItem = ({
|
||||
}}
|
||||
content={content}
|
||||
note={note}
|
||||
update={refresh}
|
||||
/>
|
||||
),
|
||||
context: "note_history"
|
||||
});
|
||||
},
|
||||
[note]
|
||||
[note, refresh]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -112,7 +117,8 @@ const HistoryItem = ({
|
||||
};
|
||||
|
||||
export default function NoteHistory({
|
||||
note
|
||||
note,
|
||||
fwdRef
|
||||
}: {
|
||||
note: Note;
|
||||
fwdRef: RefObject<ActionSheetRef>;
|
||||
@@ -121,7 +127,7 @@ export default function NoteHistory({
|
||||
const [_loading, setLoading] = useState(true);
|
||||
const { colors } = useThemeColors();
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = useCallback(() => {
|
||||
db.noteHistory
|
||||
.get(note.id)
|
||||
.sorted({
|
||||
@@ -137,17 +143,56 @@ export default function NoteHistory({
|
||||
});
|
||||
}, [note.id]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ index }: { index: number }) => (
|
||||
<HistoryItem index={index} items={history} note={note} />
|
||||
<HistoryItem
|
||||
index={index}
|
||||
items={history}
|
||||
note={note}
|
||||
refresh={refresh}
|
||||
/>
|
||||
),
|
||||
[history, note]
|
||||
[history, note, refresh]
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SheetProvider context="note_history" />
|
||||
<DialogHeader title={strings.noteHistory()} padding={12} />
|
||||
<Dialog context="local" />
|
||||
<DialogHeader
|
||||
title={strings.noteHistory()}
|
||||
padding={12}
|
||||
button={
|
||||
history?.placeholders.length
|
||||
? {
|
||||
title: strings.clearHistory(),
|
||||
type: "secondary",
|
||||
onPress: () => {
|
||||
presentDialog({
|
||||
title: strings.clearHistory(),
|
||||
paragraph: strings.clearHistoryConfirmation(),
|
||||
positiveText: strings.clear(),
|
||||
negativeText: strings.cancel(),
|
||||
positiveType: "errorShade",
|
||||
context: "local",
|
||||
positivePress: async () => {
|
||||
await db.noteHistory.clearSessions(note.id);
|
||||
fwdRef.current?.hide();
|
||||
ToastManager.show({
|
||||
heading: strings.historyCleared(),
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Seperator />
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ import {
|
||||
export default function NotePreview({
|
||||
session,
|
||||
content,
|
||||
note
|
||||
note,
|
||||
update
|
||||
}: {
|
||||
session?: HistorySession & { session: string };
|
||||
content:
|
||||
@@ -64,6 +65,7 @@ export default function NotePreview({
|
||||
>
|
||||
| undefined;
|
||||
note: TrashOrItem<Note>;
|
||||
update?: () => void;
|
||||
}) {
|
||||
const { colors } = useThemeColors();
|
||||
const [locked, setLocked] = useState(false);
|
||||
@@ -107,24 +109,35 @@ export default function NotePreview({
|
||||
}, [note]);
|
||||
|
||||
const deleteNote = async () => {
|
||||
const isSession = !!session;
|
||||
|
||||
presentDialog({
|
||||
title: strings.deleteNote(),
|
||||
paragraph: strings.deleteNoteConfirmation(),
|
||||
title: isSession ? strings.deleteVersion() : strings.deleteNote(),
|
||||
paragraph: isSession
|
||||
? strings.deleteVersionConfirmation()
|
||||
: strings.deleteNoteConfirmation(),
|
||||
positiveText: strings.delete(),
|
||||
negativeText: strings.cancel(),
|
||||
context: "local",
|
||||
context: "preview",
|
||||
positivePress: async () => {
|
||||
if (note) {
|
||||
if (isSession) {
|
||||
await db.noteHistory.remove(session.id);
|
||||
update?.();
|
||||
eSendEvent(eCloseSheet, "note_history");
|
||||
Navigation.queueRoutesForUpdate();
|
||||
} else if (note.type === "trash") {
|
||||
await db.trash.delete(note.id);
|
||||
useTrashStore.getState().refresh();
|
||||
useSelectionStore.getState().setSelectionMode();
|
||||
ToastManager.show({
|
||||
heading: strings.noteDeleted(),
|
||||
type: "success",
|
||||
context: "local"
|
||||
});
|
||||
eSendEvent(eCloseSheet);
|
||||
}
|
||||
ToastManager.show({
|
||||
heading: isSession ? strings.versionDeleted() : strings.noteDeleted(),
|
||||
type: "success",
|
||||
context: isSession ? "local" : "global"
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
positiveType: "error"
|
||||
});
|
||||
@@ -137,7 +150,7 @@ export default function NotePreview({
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<Dialog context="local" />
|
||||
<Dialog context="preview" />
|
||||
<DialogHeader
|
||||
padding={12}
|
||||
title={content?.title || note.title || session?.session}
|
||||
|
||||
@@ -112,7 +112,8 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
|
||||
}
|
||||
setStep(Steps.buy);
|
||||
}
|
||||
}, [pricingPlans, routeParams.state]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [routeParams.state]);
|
||||
|
||||
useEffect(() => {
|
||||
let listener: NativeEventSubscription;
|
||||
@@ -176,6 +177,7 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
|
||||
>
|
||||
<IconButton
|
||||
name="close"
|
||||
testID="paywall-close"
|
||||
color={colors.primary.icon}
|
||||
onPress={() => {
|
||||
Navigation.navigate("FluidPanelsView", {});
|
||||
|
||||
@@ -110,6 +110,7 @@ export const DateMeta = ({ item }: { item: Item }) => {
|
||||
}}
|
||||
maximumDate={new Date((item as Note).dateEdited)}
|
||||
isDarkModeEnabled={isDark}
|
||||
themeVariant={isDark ? "dark" : "light"}
|
||||
is24Hour={db.settings.getTimeFormat() === "24-hour"}
|
||||
date={new Date(dateCreated)}
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,8 @@ const TOP_BAR_ITEMS: ActionId[] = [
|
||||
"publish",
|
||||
"local-only",
|
||||
"read-only",
|
||||
"pin-to-notifications"
|
||||
"pin-to-notifications",
|
||||
"spell-check"
|
||||
];
|
||||
|
||||
const BOTTOM_BAR_ITEMS: ActionId[] = [
|
||||
@@ -79,6 +80,8 @@ const COLUMN_BAR_ITEMS: ActionId[] = [
|
||||
"rename-color",
|
||||
"rename-tag",
|
||||
"launcher-shortcut",
|
||||
"copy-id",
|
||||
"copy-link",
|
||||
"restore",
|
||||
"trash",
|
||||
"delete"
|
||||
|
||||
@@ -170,147 +170,147 @@ export const SelectionHeader = React.memo(
|
||||
{!selectedItemsList.length
|
||||
? null
|
||||
: (focusedRouteId === "Monographs"
|
||||
? [
|
||||
{
|
||||
title: strings.unpublish(),
|
||||
onPress: () => {
|
||||
presentDialog({
|
||||
title: strings.doActions.unpublish.note(
|
||||
selectedItemsList.length
|
||||
),
|
||||
positiveText: strings.unpublish(),
|
||||
negativeText: strings.cancel(),
|
||||
positivePress: async () => {
|
||||
for (const id of selectedItemsList) {
|
||||
await db.monographs.unpublish(id);
|
||||
}
|
||||
Navigation.queueRoutesForUpdate();
|
||||
clearSelection();
|
||||
},
|
||||
positiveType: "errorShade"
|
||||
});
|
||||
},
|
||||
visible: true,
|
||||
icon: "delete"
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: strings.manageTags(),
|
||||
onPress: async () => {
|
||||
ManageTags.present(selectedItemsList);
|
||||
},
|
||||
visible: type === "note",
|
||||
icon: "pound"
|
||||
},
|
||||
{
|
||||
title: strings.export(),
|
||||
onPress: async () => {
|
||||
ExportNotesSheet.present(selectedItemsList);
|
||||
},
|
||||
visible: type === "note",
|
||||
icon: "export"
|
||||
},
|
||||
{
|
||||
title: strings.addToNotebook(),
|
||||
onPress: async () => {
|
||||
Navigation.navigate("LinkNotebooks", {
|
||||
noteIds: selectedItemsList
|
||||
});
|
||||
},
|
||||
visible: type === "note",
|
||||
icon: "plus"
|
||||
},
|
||||
{
|
||||
title: strings.unlinkNotebook(),
|
||||
onPress: async () => {
|
||||
if (!id) return;
|
||||
await db.notes.removeFromNotebook(
|
||||
id,
|
||||
...selectedItemsList
|
||||
);
|
||||
updateNotebook(id);
|
||||
? [
|
||||
{
|
||||
title: strings.unpublish(),
|
||||
onPress: () => {
|
||||
presentDialog({
|
||||
title: strings.doActions.unpublish.note(
|
||||
selectedItemsList.length
|
||||
),
|
||||
positiveText: strings.unpublish(),
|
||||
negativeText: strings.cancel(),
|
||||
positivePress: async () => {
|
||||
for (const id of selectedItemsList) {
|
||||
await db.monographs.unpublish(id);
|
||||
}
|
||||
Navigation.queueRoutesForUpdate();
|
||||
clearSelection();
|
||||
},
|
||||
visible: renderedInRoute === "Notebook",
|
||||
icon: "minus"
|
||||
},
|
||||
{
|
||||
title: strings.unfavorite(),
|
||||
onPress: addToFavorite,
|
||||
visible: focusedRouteId === "Favorites",
|
||||
icon: "star-off"
|
||||
},
|
||||
{
|
||||
title: strings.moveToTrash(),
|
||||
onPress: async () => {
|
||||
const selection = useSelectionStore.getState();
|
||||
if (!selection.selectionMode) return;
|
||||
await deleteItems(
|
||||
selection.selectionMode as ItemType,
|
||||
selection.selectedItemsList
|
||||
);
|
||||
selection.clearSelection();
|
||||
selection.setSelectionMode(undefined);
|
||||
},
|
||||
visible: type === "note" || type === "notebook",
|
||||
icon: "delete"
|
||||
},
|
||||
{
|
||||
title: strings.doActions.delete.unknown(
|
||||
type!,
|
||||
selectedItemsList.length
|
||||
),
|
||||
onPress: async () => {
|
||||
const selection = useSelectionStore.getState();
|
||||
if (!selection.selectionMode) return;
|
||||
await deleteItems(
|
||||
selection.selectionMode as ItemType,
|
||||
selection.selectedItemsList
|
||||
);
|
||||
selection.clearSelection();
|
||||
selection.setSelectionMode(undefined);
|
||||
},
|
||||
visible:
|
||||
type !== "trash" &&
|
||||
type !== "note" &&
|
||||
type !== "notebook",
|
||||
icon: "delete"
|
||||
},
|
||||
{
|
||||
title: strings.restore(),
|
||||
onPress: restoreItem,
|
||||
visible: type === "trash",
|
||||
icon: "delete-restore"
|
||||
},
|
||||
{
|
||||
title: strings.delete(),
|
||||
onPress: deleteItem,
|
||||
visible: type === "trash",
|
||||
icon: "delete"
|
||||
}
|
||||
]
|
||||
).map((item) =>
|
||||
!item.visible ? null : (
|
||||
<IconButton
|
||||
size={AppFontSize.lg}
|
||||
type="plain"
|
||||
tooltipText={item.title}
|
||||
tooltipPosition={NativeTooltip.POSITIONS.TOP}
|
||||
testID={`select-${item.icon}`}
|
||||
name={item.icon}
|
||||
key={item.title}
|
||||
color={colors.primary.icon}
|
||||
onPress={async () => {
|
||||
//@ts-ignore
|
||||
menuRef.current?.hide();
|
||||
if (Platform.OS === "ios") await sleep(300);
|
||||
item.onPress();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
positiveType: "errorShade"
|
||||
});
|
||||
},
|
||||
visible: true,
|
||||
icon: "delete"
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: strings.manageTags(),
|
||||
onPress: async () => {
|
||||
ManageTags.present(selectedItemsList);
|
||||
},
|
||||
visible: type === "note",
|
||||
icon: "pound"
|
||||
},
|
||||
{
|
||||
title: strings.export(),
|
||||
onPress: async () => {
|
||||
ExportNotesSheet.present(selectedItemsList);
|
||||
},
|
||||
visible: type === "note",
|
||||
icon: "export"
|
||||
},
|
||||
{
|
||||
title: strings.addToNotebook(),
|
||||
onPress: async () => {
|
||||
Navigation.navigate("LinkNotebooks", {
|
||||
noteIds: selectedItemsList
|
||||
});
|
||||
},
|
||||
visible: type === "note",
|
||||
icon: "plus"
|
||||
},
|
||||
{
|
||||
title: strings.unlinkNotebook(),
|
||||
onPress: async () => {
|
||||
if (!id) return;
|
||||
await db.notes.removeFromNotebook(
|
||||
id,
|
||||
...selectedItemsList
|
||||
);
|
||||
updateNotebook(id);
|
||||
Navigation.queueRoutesForUpdate();
|
||||
clearSelection();
|
||||
},
|
||||
visible: renderedInRoute === "Notebook",
|
||||
icon: "minus"
|
||||
},
|
||||
{
|
||||
title: strings.unfavorite(),
|
||||
onPress: addToFavorite,
|
||||
visible: focusedRouteId === "Favorites",
|
||||
icon: "star-off"
|
||||
},
|
||||
{
|
||||
title: strings.moveToTrash(),
|
||||
onPress: async () => {
|
||||
const selection = useSelectionStore.getState();
|
||||
if (!selection.selectionMode) return;
|
||||
await deleteItems(
|
||||
selection.selectionMode as ItemType,
|
||||
selection.selectedItemsList
|
||||
);
|
||||
selection.clearSelection();
|
||||
selection.setSelectionMode(undefined);
|
||||
},
|
||||
visible: type === "note" || type === "notebook",
|
||||
icon: "delete"
|
||||
},
|
||||
{
|
||||
title: strings.doActions.delete.unknown(
|
||||
type!,
|
||||
selectedItemsList.length
|
||||
),
|
||||
onPress: async () => {
|
||||
const selection = useSelectionStore.getState();
|
||||
if (!selection.selectionMode) return;
|
||||
await deleteItems(
|
||||
selection.selectionMode as ItemType,
|
||||
selection.selectedItemsList
|
||||
);
|
||||
selection.clearSelection();
|
||||
selection.setSelectionMode(undefined);
|
||||
},
|
||||
visible:
|
||||
type !== "trash" &&
|
||||
type !== "note" &&
|
||||
type !== "notebook",
|
||||
icon: "delete"
|
||||
},
|
||||
{
|
||||
title: strings.restore(),
|
||||
onPress: restoreItem,
|
||||
visible: type === "trash",
|
||||
icon: "delete-restore"
|
||||
},
|
||||
{
|
||||
title: strings.delete(),
|
||||
onPress: deleteItem,
|
||||
visible: type === "trash",
|
||||
icon: "delete"
|
||||
}
|
||||
]
|
||||
).map((item) =>
|
||||
!item.visible ? null : (
|
||||
<IconButton
|
||||
size={AppFontSize.lg}
|
||||
type="plain"
|
||||
tooltipText={item.title}
|
||||
tooltipPosition={NativeTooltip.POSITIONS.TOP}
|
||||
testID={`select-${item.icon}`}
|
||||
name={item.icon}
|
||||
key={item.title}
|
||||
color={colors.primary.icon}
|
||||
onPress={async () => {
|
||||
//@ts-ignore
|
||||
menuRef.current?.hide();
|
||||
if (Platform.OS === "ios") await sleep(300);
|
||||
item.onPress();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
|
||||
<IconButton
|
||||
|
||||
183
apps/mobile/app/components/sheets/add-api-key/index.tsx
Normal file
183
apps/mobile/app/components/sheets/add-api-key/index.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
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 React, { useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { db } from "../../../common/database";
|
||||
import { Button } from "../../ui/button";
|
||||
import FormInput, {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../ui/input/form-input";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import Heading from "../../ui/typography/heading";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import { AppFontSize } from "../../../utils/size";
|
||||
import { ToastManager, presentSheet } from "../../../services/event-manager";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import { Pressable } from "../../ui/pressable";
|
||||
import { ScrollView } from "react-native-actions-sheet";
|
||||
|
||||
const getExpiryOptions = () => [
|
||||
{ label: strings.expiryOneDay(), value: 24 * 60 * 60 * 1000 },
|
||||
{ label: strings.expiryOneWeek(), value: 7 * 24 * 60 * 60 * 1000 },
|
||||
{ label: strings.expiryOneMonth(), value: 30 * 24 * 60 * 60 * 1000 },
|
||||
{ label: strings.expiryOneYear(), value: 365 * 24 * 60 * 60 * 1000 },
|
||||
{ label: strings.never(), value: -1 }
|
||||
];
|
||||
|
||||
type AddApiKeySheetProps = {
|
||||
close?: (ctx?: string | undefined) => void;
|
||||
onAdd: () => void;
|
||||
};
|
||||
|
||||
export default function AddApiKeySheet({ close, onAdd }: AddApiKeySheetProps) {
|
||||
const { colors } = useThemeColors();
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
keyName: ""
|
||||
})
|
||||
);
|
||||
const [selectedExpiry, setSelectedExpiry] = useState(
|
||||
getExpiryOptions()[2].value
|
||||
);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
if (formRef.current.validateField("keyName")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyName = formRef.current.getValue("keyName").trim();
|
||||
|
||||
setIsCreating(true);
|
||||
await db.inboxApiKeys.create(keyName, selectedExpiry);
|
||||
ToastManager.show({
|
||||
message: strings.apiKeyCreatedSuccessfully(),
|
||||
type: "success"
|
||||
});
|
||||
onAdd();
|
||||
close?.();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
ToastManager.show({
|
||||
message: strings.failedToCreateApiKey(message),
|
||||
type: "error"
|
||||
});
|
||||
formRef.current.setError(
|
||||
"keyName",
|
||||
message || strings.failedToCreateApiKey("")
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: DefaultAppStyles.GAP,
|
||||
gap: DefaultAppStyles.GAP_VERTICAL,
|
||||
paddingTop: DefaultAppStyles.GAP_VERTICAL,
|
||||
paddingBottom: DefaultAppStyles.GAP_VERTICAL * 2
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Heading size={AppFontSize.xl}>{strings.createApiKey()}</Heading>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: DefaultAppStyles.GAP_VERTICAL }}>
|
||||
<Paragraph size={AppFontSize.sm}>{strings.keyName()}</Paragraph>
|
||||
<FormInput
|
||||
name="keyName"
|
||||
formRef={formRef}
|
||||
placeholder={strings.exampleKeyName()}
|
||||
validators={[validators.required(strings.enterKeyName())]}
|
||||
onChangeText={() => {
|
||||
formRef.current.setError("keyName", undefined);
|
||||
}}
|
||||
onSubmitEditing={handleCreate}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: DefaultAppStyles.GAP_VERTICAL }}>
|
||||
<Paragraph size={AppFontSize.sm}>{strings.expiresIn()}</Paragraph>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: DefaultAppStyles.GAP_SMALL
|
||||
}}
|
||||
>
|
||||
{getExpiryOptions().map((option) => (
|
||||
<Pressable
|
||||
key={option.label}
|
||||
onPress={() => setSelectedExpiry(option.value)}
|
||||
type={
|
||||
selectedExpiry === option.value ? "selected" : "transparent"
|
||||
}
|
||||
style={{
|
||||
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
|
||||
paddingHorizontal: DefaultAppStyles.GAP
|
||||
}}
|
||||
>
|
||||
<Paragraph
|
||||
size={AppFontSize.sm}
|
||||
color={
|
||||
selectedExpiry === option.value
|
||||
? colors.selected.paragraph
|
||||
: colors.primary.paragraph
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Paragraph>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title={isCreating ? strings.creating() : strings.create()}
|
||||
type="accent"
|
||||
width="100%"
|
||||
loading={isCreating}
|
||||
disabled={isCreating}
|
||||
onPress={handleCreate}
|
||||
style={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
AddApiKeySheet.present = (onAdd: () => void) => {
|
||||
presentSheet({
|
||||
component: (ref, close, _update) => (
|
||||
<AddApiKeySheet close={close} onAdd={onAdd} />
|
||||
)
|
||||
});
|
||||
};
|
||||
@@ -33,11 +33,11 @@ import { editorController } from "../../../screens/editor/tiptap/utils";
|
||||
import { eSendEvent, presentSheet } from "../../../services/event-manager";
|
||||
import { eUnlockNote } from "../../../utils/events";
|
||||
import { AppFontSize } from "../../../utils/size";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import { IconButton } from "../../ui/icon-button";
|
||||
import { Pressable } from "../../ui/pressable";
|
||||
import Heading from "../../ui/typography/heading";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
|
||||
const TabItemComponent = (props: {
|
||||
tab: TabItem;
|
||||
@@ -225,14 +225,24 @@ export default function EditorTabs({
|
||||
}}
|
||||
>
|
||||
<Heading size={AppFontSize.lg}>{strings.tabs()}</Heading>
|
||||
<IconButton
|
||||
onPress={() => {
|
||||
useTabStore.getState().newTab();
|
||||
close?.();
|
||||
}}
|
||||
name="plus"
|
||||
color={colors.primary.accent}
|
||||
/>
|
||||
<View style={{ flexDirection: "row", gap: DefaultAppStyles.GAP_SMALL }}>
|
||||
<IconButton
|
||||
onPress={() => {
|
||||
useTabStore.getState().clearAllTabs();
|
||||
close?.();
|
||||
}}
|
||||
name="close-box-multiple-outline"
|
||||
color={colors.primary.icon}
|
||||
/>
|
||||
<IconButton
|
||||
onPress={() => {
|
||||
useTabStore.getState().newTab();
|
||||
close?.();
|
||||
}}
|
||||
name="plus"
|
||||
color={colors.primary.accent}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
@@ -253,4 +263,4 @@ EditorTabs.present = () => {
|
||||
presentSheet({
|
||||
component: (ref, close, update) => <EditorTabs close={close} />
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -28,7 +28,7 @@ import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { ActivityIndicator, TextInput, View } from "react-native";
|
||||
import { FlatList } from "react-native-actions-sheet";
|
||||
import { db } from "../../../common/database";
|
||||
import { useDBItem } from "../../../hooks/use-db-item";
|
||||
@@ -40,6 +40,8 @@ import { Button } from "../../ui/button";
|
||||
import Input from "../../ui/input";
|
||||
import { Pressable } from "../../ui/pressable";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import Navigation from "../../../services/navigation";
|
||||
import { useUserStore } from "../../../stores/use-user-store";
|
||||
|
||||
const ListNoteItem = ({
|
||||
id,
|
||||
@@ -118,8 +120,8 @@ const ListBlockItem = ({
|
||||
{item?.content.length > 200
|
||||
? item?.content.slice(0, 200) + "..."
|
||||
: !item.content || item.content.trim() === ""
|
||||
? strings.linkNoteEmptyBlock()
|
||||
: item.content}
|
||||
? strings.linkNoteEmptyBlock()
|
||||
: item.content}
|
||||
</Paragraph>
|
||||
|
||||
<View
|
||||
@@ -158,6 +160,7 @@ export default function LinkNote(props: {
|
||||
|
||||
const [selectedNote, setSelectedNote] = useState<Note>();
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>();
|
||||
const [blocksLoading, setBlocksLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
db.notes.all.sorted(db.settings.getGroupOptions("notes")).then((notes) => {
|
||||
@@ -206,10 +209,12 @@ export default function LinkNote(props: {
|
||||
|
||||
const onSelectNote = async (note: Note) => {
|
||||
setSelectedNote(note);
|
||||
setBlocksLoading(true);
|
||||
inputRef.current?.clear();
|
||||
setTimeout(async () => {
|
||||
nodesRef.current = await db.notes.contentBlocks(note.id);
|
||||
setNodes(nodesRef.current);
|
||||
setBlocksLoading(false);
|
||||
});
|
||||
// Fetch and set note's nodes.
|
||||
};
|
||||
@@ -224,6 +229,7 @@ export default function LinkNote(props: {
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: DefaultAppStyles.GAP,
|
||||
paddingBottom: DefaultAppStyles.GAP,
|
||||
minHeight: "100%",
|
||||
maxHeight: "100%"
|
||||
}}
|
||||
@@ -330,28 +336,42 @@ export default function LinkNote(props: {
|
||||
windowSize={3}
|
||||
keyExtractor={(item) => item.id}
|
||||
ListEmptyComponent={
|
||||
<View
|
||||
style={{
|
||||
gap: DefaultAppStyles.GAP_VERTICAL,
|
||||
backgroundColor: colors.secondary.background,
|
||||
padding: DefaultAppStyles.GAP,
|
||||
borderRadius: defaultBorderRadius,
|
||||
borderWidth: 0.5,
|
||||
borderColor: colors.secondary.border,
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Paragraph color={colors.secondary.paragraph}>
|
||||
{blockLinking?.error}
|
||||
</Paragraph>
|
||||
<Button
|
||||
title={strings.upgradePlan()}
|
||||
!blockLinking || blocksLoading ? (
|
||||
<ActivityIndicator size={25} color={colors.primary.accent} />
|
||||
) : !blockLinking.isAllowed ? (
|
||||
<View
|
||||
style={{
|
||||
width: "100%"
|
||||
gap: DefaultAppStyles.GAP_VERTICAL,
|
||||
backgroundColor: colors.secondary.background,
|
||||
padding: DefaultAppStyles.GAP,
|
||||
borderRadius: defaultBorderRadius,
|
||||
borderWidth: 0.5,
|
||||
borderColor: colors.secondary.border,
|
||||
alignItems: "center"
|
||||
}}
|
||||
type="accent"
|
||||
/>
|
||||
</View>
|
||||
>
|
||||
<Paragraph color={colors.secondary.paragraph}>
|
||||
{blockLinking?.error}
|
||||
</Paragraph>
|
||||
<Button
|
||||
title={strings.upgradePlan()}
|
||||
style={{
|
||||
width: "100%"
|
||||
}}
|
||||
type="accent"
|
||||
onPress={() => {
|
||||
Navigation.navigate("PayWall", {
|
||||
context: useUserStore.getState().user
|
||||
? "logged-in"
|
||||
: "logged-out",
|
||||
canGoBack: true
|
||||
});
|
||||
|
||||
props.close?.();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
data={blockLinking?.isAllowed ? nodes : []}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 { hosts, Note } from "@notesnook/core";
|
||||
import { Note } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import Clipboard from "@react-native-clipboard/clipboard";
|
||||
@@ -45,21 +45,30 @@ import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import DialogHeader from "../../dialog/dialog-header";
|
||||
import { Button } from "../../ui/button";
|
||||
import { IconButton } from "../../ui/icon-button";
|
||||
import Input from "../../ui/input";
|
||||
import Heading from "../../ui/typography/heading";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import { useAsync } from "react-async-hook";
|
||||
import { eMenuItemUpdate } from "../../../utils/events";
|
||||
import { useIsFeatureAvailable } from "@notesnook/common";
|
||||
import FormInput, {
|
||||
createFormRef,
|
||||
validators
|
||||
} from "../../ui/input/form-input";
|
||||
import { useAppState } from "../../../../app/hooks/use-app-state";
|
||||
|
||||
async function fetchMonographData(noteId: string) {
|
||||
const monographId = db.monographs.monograph(noteId);
|
||||
const monograph = monographId
|
||||
? await db.monographs.get(monographId)
|
||||
: undefined;
|
||||
|
||||
const metadata = monographId
|
||||
? await db.monographs.metadata(monographId)
|
||||
: { publishUrl: "", analytics: { totalViews: 0 } };
|
||||
return {
|
||||
monograph,
|
||||
monographId
|
||||
monographId,
|
||||
metadata
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,26 +85,54 @@ const PublishNoteSheet = ({
|
||||
const isFeatureAvailable = useIsFeatureAvailable("monographAnalytics");
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const customTitle = useRef<string>("");
|
||||
const pwdInput = useRef<TextInput>(null);
|
||||
const titleInput = useRef<TextInput>(null);
|
||||
const passwordValue = useRef<string>(undefined);
|
||||
const monographData = useAsync(async () => {
|
||||
return fetchMonographData(note?.id);
|
||||
}, []);
|
||||
const lastMonographDataResult = useRef<Awaited<
|
||||
ReturnType<typeof db.monographs.metadata>
|
||||
> | null>(null);
|
||||
const monographData = useAsync(
|
||||
async () => {
|
||||
return fetchMonographData(note?.id);
|
||||
},
|
||||
[],
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
lastMonographDataResult.current = r.metadata;
|
||||
}
|
||||
}
|
||||
);
|
||||
const monograph = monographData.result?.monograph;
|
||||
customTitle.current = monograph?.title || note.title || "";
|
||||
const publishUrl = monograph && `${hosts.MONOGRAPH_HOST}/${monograph?.id}`;
|
||||
const metadata = monographData.result?.metadata;
|
||||
const publishUrl = metadata?.publishUrl || monograph?.publishUrl || "";
|
||||
const isPublished = db.monographs.monograph(note?.id);
|
||||
const appState = useAppState();
|
||||
const previousAppState = useRef(appState);
|
||||
|
||||
const formRef = useRef(
|
||||
createFormRef({
|
||||
title: note.title || "",
|
||||
password: ""
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!monographData.result) return;
|
||||
const title = monograph?.title || note.title || "";
|
||||
formRef.current.setValue("title", title);
|
||||
setTimeout(() => {
|
||||
titleInput.current?.setNativeProps({ text: title });
|
||||
}, 50);
|
||||
}, [monographData.result, monograph]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (monograph) {
|
||||
setSelfDestruct(!!monograph?.selfDestruct);
|
||||
if (monograph.password) {
|
||||
passwordValue.current = await db.monographs.decryptPassword(
|
||||
const password = await db.monographs.decryptPassword(
|
||||
monograph?.password
|
||||
);
|
||||
formRef.current.setValue("password", password);
|
||||
setIsLocked(!!monograph?.password);
|
||||
}
|
||||
}
|
||||
@@ -104,20 +141,23 @@ const PublishNoteSheet = ({
|
||||
|
||||
const publishNote = async () => {
|
||||
if (publishing) return;
|
||||
formRef.current.clearErrors();
|
||||
|
||||
if (!formRef.current.validate()) return;
|
||||
|
||||
const values = formRef.current.getValues();
|
||||
|
||||
setPublishLoading(true);
|
||||
|
||||
try {
|
||||
if (note?.id) {
|
||||
if (isLocked && !passwordValue.current) return;
|
||||
await db.monographs.publish(note.id, customTitle.current, {
|
||||
selfDestruct: selfDestruct,
|
||||
password: isLocked ? passwordValue.current : undefined
|
||||
await db.monographs.publish(note.id, values.title, {
|
||||
selfDestruct,
|
||||
password: isLocked ? values.password : undefined
|
||||
});
|
||||
|
||||
await monographData.execute();
|
||||
Navigation.queueRoutesForUpdate();
|
||||
eSendEvent(eMenuItemUpdate);
|
||||
setPublishLoading(false);
|
||||
}
|
||||
requestInAppReview();
|
||||
} catch (e) {
|
||||
@@ -127,9 +167,9 @@ const PublishNoteSheet = ({
|
||||
type: "error",
|
||||
context: "local"
|
||||
});
|
||||
} finally {
|
||||
setPublishLoading(false);
|
||||
}
|
||||
|
||||
setPublishLoading(false);
|
||||
};
|
||||
const setPublishLoading = (value: boolean) => {
|
||||
setPublishing(value);
|
||||
@@ -157,6 +197,29 @@ const PublishNoteSheet = ({
|
||||
setPublishLoading(false);
|
||||
};
|
||||
|
||||
const monographMetadata =
|
||||
monographData.result?.metadata ?? lastMonographDataResult.current;
|
||||
|
||||
useEffect(() => {
|
||||
const prevState = previousAppState.current;
|
||||
previousAppState.current = appState;
|
||||
|
||||
if (
|
||||
appState === "active" &&
|
||||
prevState !== "active" &&
|
||||
monograph?.id &&
|
||||
!selfDestruct
|
||||
) {
|
||||
monographData.execute();
|
||||
}
|
||||
}, [
|
||||
appState,
|
||||
monograph?.id,
|
||||
selfDestruct,
|
||||
isFeatureAvailable?.isAllowed,
|
||||
monographData
|
||||
]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -247,11 +310,17 @@ const PublishNoteSheet = ({
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
<FormInput
|
||||
name="title"
|
||||
formRef={formRef}
|
||||
fwdRef={titleInput}
|
||||
onChangeText={(value) => (customTitle.current = value)}
|
||||
defaultValue={customTitle.current}
|
||||
multiline
|
||||
scrollEnabled
|
||||
containerStyle={{
|
||||
maxHeight: 100
|
||||
}}
|
||||
placeholder={strings.noteTitle()}
|
||||
validators={[validators.required(strings.titleIsRequired())]}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -298,13 +367,16 @@ const PublishNoteSheet = ({
|
||||
|
||||
{isLocked ? (
|
||||
<>
|
||||
<Input
|
||||
<FormInput
|
||||
name="password"
|
||||
formRef={formRef}
|
||||
fwdRef={pwdInput}
|
||||
onChangeText={(value) => (passwordValue.current = value)}
|
||||
blurOnSubmit
|
||||
secureTextEntry
|
||||
defaultValue={passwordValue.current}
|
||||
placeholder={strings.enterPassword()}
|
||||
validators={[
|
||||
validators.required(strings.passwordRequired())
|
||||
]}
|
||||
containerStyle={{
|
||||
marginTop: DefaultAppStyles.GAP_VERTICAL
|
||||
}}
|
||||
@@ -357,7 +429,10 @@ const PublishNoteSheet = ({
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isFeatureAvailable?.isAllowed ? (
|
||||
{isFeatureAvailable?.isAllowed &&
|
||||
!selfDestruct &&
|
||||
monographMetadata &&
|
||||
monographMetadata?.analytics?.totalViews > 0 ? (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
@@ -381,6 +456,9 @@ const PublishNoteSheet = ({
|
||||
}}
|
||||
>
|
||||
<Paragraph size={AppFontSize.sm}>{strings.views()}</Paragraph>
|
||||
<Paragraph>
|
||||
{monographMetadata?.analytics?.totalViews || 0}
|
||||
</Paragraph>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { sanitizeFilename } from "@notesnook/common";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import Clipboard from "@react-native-clipboard/clipboard";
|
||||
import React, { createRef } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { PermissionsAndroid, Platform, View } from "react-native";
|
||||
import RNFetchBlob from "react-native-blob-util";
|
||||
import FileViewer from "react-native-file-viewer";
|
||||
import * as ScopedStorage from "react-native-scoped-storage";
|
||||
@@ -45,6 +45,7 @@ import SheetWrapper from "../../ui/sheet";
|
||||
import { QRCode } from "../../ui/svg/lazy";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import { CameraRoll } from "@react-native-camera-roll/camera-roll";
|
||||
|
||||
class RecoveryKeySheet extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -108,22 +109,14 @@ class RecoveryKeySheet extends React.Component {
|
||||
saveQRCODE = async () => {
|
||||
this.svg.current?.toDataURL(async (data) => {
|
||||
try {
|
||||
let path;
|
||||
let fileName = "nn_" + this.user.email + "_recovery_key_qrcode";
|
||||
let fileName =
|
||||
"nn_" + this.user.email + "_recovery_key_qrcode" + "_" + Date.now();
|
||||
fileName = sanitizeFilename(fileName, { replacement: "_" });
|
||||
fileName = fileName + ".png";
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
await ScopedStorage.createDocument(
|
||||
fileName,
|
||||
"image/png",
|
||||
data,
|
||||
"base64"
|
||||
);
|
||||
} else {
|
||||
path = await filesystem.checkAndCreateDir("/");
|
||||
await RNFetchBlob.fs.writeFile(path + fileName, data, "base64");
|
||||
}
|
||||
const path = RNFetchBlob.fs.dirs.CacheDir + fileName;
|
||||
await RNFetchBlob.fs.writeFile(path, data, "base64");
|
||||
await CameraRoll.saveToCameraRoll(`file://` + path);
|
||||
ToastManager.show({
|
||||
heading: strings.recoveryKeyQRCodeSaved(),
|
||||
type: "success",
|
||||
|
||||
@@ -104,8 +104,8 @@ const ListBlockItem = ({
|
||||
{item?.content.length > 200
|
||||
? item?.content.slice(0, 200) + "..."
|
||||
: !item.content || item.content.trim() === ""
|
||||
? strings.linkNoteEmptyBlock()
|
||||
: item.content}
|
||||
? strings.linkNoteEmptyBlock()
|
||||
: item.content}
|
||||
</Paragraph>
|
||||
|
||||
<View
|
||||
@@ -202,7 +202,7 @@ const ListNoteItem = ({
|
||||
items: VirtualizedGrouping<Note> | undefined;
|
||||
onSelect: (item: Note, blockId?: string) => void;
|
||||
reference: Note;
|
||||
internalLinks: MutableRefObject<InternalLink<"note">[] | undefined>;
|
||||
internalLinks: MutableRefObject<InternalLink[] | undefined>;
|
||||
listType: "linkedNotes" | "referencedIn";
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
@@ -231,7 +231,10 @@ const ListNoteItem = ({
|
||||
internalLinks.current = await db.notes.internalLinks(reference.id);
|
||||
}
|
||||
const noteLinks = internalLinks.current.filter(
|
||||
(link) => link.id === item.id && link.params?.blockId
|
||||
(link) =>
|
||||
link.id === item.id &&
|
||||
link.type === "note" &&
|
||||
link.params?.blockId
|
||||
);
|
||||
|
||||
if (noteLinks.length) {
|
||||
@@ -239,7 +242,10 @@ const ListNoteItem = ({
|
||||
|
||||
setLinkedBlocks(
|
||||
blocks.filter((block) =>
|
||||
noteLinks.find((link) => block.id === link.params?.blockId)
|
||||
noteLinks.find(
|
||||
(link) =>
|
||||
link.type === "note" && block.id === link.params?.blockId
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -404,7 +410,7 @@ export const ReferencesList = ({ item, close }: ReferencesListProps) => {
|
||||
const { colors } = useThemeColors();
|
||||
const [items, setItems] = useState<VirtualizedGrouping<Note>>();
|
||||
const hasNoRelations = !items || items?.placeholders?.length === 0;
|
||||
const internalLinks = useRef<InternalLink<"note">[]>([]);
|
||||
const internalLinks = useRef<InternalLink[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
db.relations?.[tab === 0 ? "from" : "to"]?.(
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
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 { Item, ItemReference, VirtualizedGrouping } from "@notesnook/core";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { RefObject, useEffect, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
|
||||
import { db } from "../../../common/database";
|
||||
import {
|
||||
PresentSheetOptions,
|
||||
presentSheet
|
||||
} from "../../../services/event-manager";
|
||||
import { useRelationStore } from "../../../stores/use-relation-store";
|
||||
import { AppFontSize } from "../../../utils/size";
|
||||
import { DefaultAppStyles } from "../../../utils/styles";
|
||||
import DialogHeader from "../../dialog/dialog-header";
|
||||
import List from "../../list";
|
||||
import SheetProvider from "../../sheet-provider";
|
||||
import { Button, ButtonProps } from "../../ui/button";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
|
||||
type RelationsListProps = {
|
||||
actionSheetRef: RefObject<ActionSheetRef>;
|
||||
close?: () => void;
|
||||
update?: (options: PresentSheetOptions) => void;
|
||||
item: { id: string; type: string };
|
||||
referenceType: string;
|
||||
relationType: "to" | "from";
|
||||
title: string;
|
||||
button?: ButtonProps;
|
||||
onAdd: () => void;
|
||||
};
|
||||
|
||||
const IconsByType = {
|
||||
reminder: "bell"
|
||||
};
|
||||
|
||||
export const RelationsList = ({
|
||||
actionSheetRef,
|
||||
item,
|
||||
referenceType,
|
||||
relationType,
|
||||
title,
|
||||
button,
|
||||
onAdd
|
||||
}: RelationsListProps) => {
|
||||
const updater = useRelationStore((state) => state.updater);
|
||||
const { colors } = useThemeColors();
|
||||
const [items, setItems] = useState<VirtualizedGrouping<Item>>();
|
||||
const hasNoRelations = !items || items?.placeholders?.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
db.relations?.[relationType]?.(
|
||||
{ id: item?.id, type: item?.type } as ItemReference,
|
||||
referenceType as any
|
||||
)
|
||||
.selector.sorted({
|
||||
sortBy: "dateEdited",
|
||||
sortDirection: "desc"
|
||||
})
|
||||
.then((grouped) => {
|
||||
setTimeout(() => {
|
||||
setItems(grouped);
|
||||
}, 300);
|
||||
});
|
||||
}, [relationType, referenceType, item?.id, item?.type, updater]);
|
||||
|
||||
return (
|
||||
<View style={{ paddingHorizontal: DefaultAppStyles.GAP, height: "100%" }}>
|
||||
<SheetProvider context="local" />
|
||||
<DialogHeader
|
||||
title={title}
|
||||
button={hasNoRelations ? undefined : button}
|
||||
/>
|
||||
{hasNoRelations ? (
|
||||
<View
|
||||
style={{
|
||||
height: "85%",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={IconsByType[referenceType as keyof typeof IconsByType]}
|
||||
size={60}
|
||||
color={colors.primary.icon}
|
||||
/>
|
||||
<Paragraph>{strings.noLinksFound()}</Paragraph>
|
||||
<Button
|
||||
onPress={() => {
|
||||
onAdd?.();
|
||||
}}
|
||||
fontSize={AppFontSize.sm}
|
||||
// width="100%"
|
||||
type="inverted"
|
||||
icon="plus"
|
||||
title={strings.addItem(
|
||||
referenceType as "notebook" | "tag" | "reminder" | "note"
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<List
|
||||
data={items}
|
||||
loading={false}
|
||||
groupType={
|
||||
referenceType === "note"
|
||||
? "notes"
|
||||
: referenceType === "tag"
|
||||
? "tags"
|
||||
: referenceType === "notebook"
|
||||
? "notebooks"
|
||||
: referenceType === "reminder"
|
||||
? "reminders"
|
||||
: "notes"
|
||||
}
|
||||
dataType={referenceType as any}
|
||||
isRenderedInActionSheet={true}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
RelationsList.present = ({
|
||||
reference,
|
||||
referenceType,
|
||||
relationType,
|
||||
title,
|
||||
button,
|
||||
onAdd
|
||||
}: {
|
||||
reference: { id: string; type: string };
|
||||
referenceType: string;
|
||||
relationType: "to" | "from";
|
||||
title: string;
|
||||
button?: ButtonProps;
|
||||
onAdd: () => void;
|
||||
}) => {
|
||||
presentSheet({
|
||||
component: (ref, close, update) => (
|
||||
<RelationsList
|
||||
actionSheetRef={ref}
|
||||
close={close}
|
||||
update={update}
|
||||
item={reference}
|
||||
referenceType={referenceType}
|
||||
relationType={relationType}
|
||||
title={title}
|
||||
button={button}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
)
|
||||
});
|
||||
};
|
||||
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
GroupingByIdKey,
|
||||
GroupingKey,
|
||||
GroupOptions,
|
||||
ItemType,
|
||||
@@ -27,7 +28,10 @@ import { strings } from "@notesnook/intl";
|
||||
import { useThemeColors } from "@notesnook/theme";
|
||||
import React, { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { db } from "../../../common/database";
|
||||
import {
|
||||
getGroupOptions,
|
||||
setGroupOptionsById
|
||||
} from "../../../hooks/use-group-options";
|
||||
import { eSendEvent } from "../../../services/event-manager";
|
||||
import Navigation from "../../../services/navigation";
|
||||
import { RouteName } from "../../../stores/use-navigation-store";
|
||||
@@ -43,20 +47,24 @@ import { Pressable } from "../../ui/pressable";
|
||||
import Heading from "../../ui/typography/heading";
|
||||
import Paragraph from "../../ui/typography/paragraph";
|
||||
const Sort = ({
|
||||
type,
|
||||
dataType,
|
||||
screen,
|
||||
hideGroupOptions,
|
||||
group: groupType
|
||||
group: groupType,
|
||||
groupId,
|
||||
type
|
||||
}: {
|
||||
type: ItemType;
|
||||
dataType: ItemType;
|
||||
type?: GroupingByIdKey;
|
||||
screen?: RouteName;
|
||||
group: GroupingKey;
|
||||
hideGroupOptions?: boolean;
|
||||
groupId?: string;
|
||||
}) => {
|
||||
const { colors } = useThemeColors();
|
||||
|
||||
const [groupOptions, setGroupOptions] = useState(
|
||||
db.settings.getGroupOptions(groupType)
|
||||
getGroupOptions(groupType, groupId, type)
|
||||
);
|
||||
|
||||
const getSortButtonTitle = () => {
|
||||
@@ -79,16 +87,17 @@ const Sort = ({
|
||||
};
|
||||
|
||||
const updateGroupOptions = async (_groupOptions: GroupOptions) => {
|
||||
await db.settings.setGroupOptions(groupType, _groupOptions);
|
||||
console.log(groupId, type);
|
||||
setGroupOptionsById(groupType, _groupOptions, groupId, type);
|
||||
setGroupOptions(_groupOptions);
|
||||
setTimeout(() => {
|
||||
if (screen) Navigation.queueRoutesForUpdate(screen);
|
||||
if (type === "notebook") {
|
||||
if (dataType === "notebook") {
|
||||
useNotebookStore.getState().refresh();
|
||||
} else if (type === "tag") {
|
||||
} else if (dataType === "tag") {
|
||||
useTagStore.getState().refresh();
|
||||
}
|
||||
eSendEvent(eGroupOptionsUpdated, groupType);
|
||||
eSendEvent(eGroupOptionsUpdated, groupType, groupId, type);
|
||||
eSendEvent(refreshNotesPage);
|
||||
}, 1);
|
||||
};
|
||||
|
||||
@@ -493,7 +493,7 @@ const TabBar = (props: SimpleTabBarProps) => {
|
||||
presentSheet({
|
||||
component: (
|
||||
<Sort
|
||||
type={
|
||||
dataType={
|
||||
props.navigationState.index === 1
|
||||
? "notebook"
|
||||
: "tag"
|
||||
|
||||
@@ -156,7 +156,8 @@ export function MenuItem({
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: DefaultAppStyles.GAP_SMALL
|
||||
gap: DefaultAppStyles.GAP_SMALL,
|
||||
flexShrink: 1
|
||||
}}
|
||||
>
|
||||
{renderIcon ? (
|
||||
@@ -185,6 +186,7 @@ export function MenuItem({
|
||||
isFocused ? colors.selected.paragraph : colors.primary.paragraph
|
||||
}
|
||||
size={AppFontSize.sm}
|
||||
style={{ flexShrink: 1 }}
|
||||
>
|
||||
{item.title}
|
||||
</Paragraph>
|
||||
@@ -195,6 +197,9 @@ export function MenuItem({
|
||||
color={
|
||||
isFocused ? colors.primary.paragraph : colors.secondary.paragraph
|
||||
}
|
||||
style={{
|
||||
marginLeft: DefaultAppStyles.GAP_SMALL
|
||||
}}
|
||||
>
|
||||
{menuItemCount}
|
||||
</Paragraph>
|
||||
|
||||
@@ -72,7 +72,7 @@ export const NotebookItem = ({
|
||||
const notebook = item.notebook;
|
||||
const isFocused = focused;
|
||||
const { totalNotes, getTotalNotes } = useTotalNotes("notebook");
|
||||
const updater = useRelationStore(state => state.updater);
|
||||
const updater = useRelationStore((state) => state.updater);
|
||||
const getTotalNotesRef = React.useRef(getTotalNotes);
|
||||
getTotalNotesRef.current = getTotalNotes;
|
||||
const { colors } = useThemeColors();
|
||||
@@ -101,8 +101,8 @@ export const NotebookItem = ({
|
||||
item.depth === 0
|
||||
? undefined
|
||||
: item.depth < 6
|
||||
? 15 * item.depth
|
||||
: 15 * 5,
|
||||
? 15 * item.depth
|
||||
: 15 * 5,
|
||||
width: "100%",
|
||||
marginTop: 2,
|
||||
opacity: item.disabled ? 0.5 : 1
|
||||
@@ -162,7 +162,8 @@ export const NotebookItem = ({
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center"
|
||||
alignItems: "center",
|
||||
flexShrink: 1
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
@@ -191,8 +192,8 @@ export const NotebookItem = ({
|
||||
!item.hasChildren || disableExpand
|
||||
? "book-outline"
|
||||
: expanded
|
||||
? "chevron-down"
|
||||
: "chevron-right"
|
||||
? "chevron-down"
|
||||
: "chevron-right"
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -201,6 +202,7 @@ export const NotebookItem = ({
|
||||
isFocused ? colors.selected.paragraph : colors.primary.paragraph
|
||||
}
|
||||
size={AppFontSize.sm}
|
||||
style={{ flexShrink: 1 }}
|
||||
>
|
||||
{notebook?.title}
|
||||
</Paragraph>
|
||||
@@ -211,7 +213,8 @@ export const NotebookItem = ({
|
||||
gap: DefaultAppStyles.GAP_SMALL,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
justifyContent: "center",
|
||||
marginLeft: DefaultAppStyles.GAP_SMALL
|
||||
}}
|
||||
>
|
||||
{selectionEnabled ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user