commit 31e631027b116d277b7c6953e0f0732e99e4c466 Author: geek Date: Fri Jul 10 17:09:39 2026 +0800 初始化简单写作项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67c1384 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +android/build/ +android/.gradle/ +android/local.properties +android/app/build/ +android/captures/ +*.log +.DS_Store +Thumbs.db +dist/ diff --git a/.sync/data/u1783657002516.json b/.sync/data/u1783657002516.json new file mode 100644 index 0000000..b0e5235 --- /dev/null +++ b/.sync/data/u1783657002516.json @@ -0,0 +1 @@ +{"docs":[{"id":"x1","title":"a","content":"b","updated":5}]} \ No newline at end of file diff --git a/.sync/users.json b/.sync/users.json new file mode 100644 index 0000000..a2d9e21 --- /dev/null +++ b/.sync/users.json @@ -0,0 +1,6 @@ +{ + "u1783657002516": { + "salt": "9c78238b2d54a790b0bc3b9ab74d6620", + "hash": "0957239573eb4216a2d0d83e1c83cbc7bda93616e5cbd350d6fd8d77d8687ee8e1d95f27c5d1e6d992758ed8e4183b555782fbb545a7612dbec6b43891169913" + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bafab28 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# 简单写作(跨平台极简写作 PWA) + +一个极简风格的写作工具,原生跨平台:浏览器打开即用,可安装到 Windows 桌面与安卓主屏,支持离线,并带**基于 WebDAV 的端到端加密多端同步**。 + +## 功能 +- 极简纸张风格写作界面 +- **打字机滚动**:当前输入行始终保持在屏幕中央(点工具栏按钮开关,默认开启) +- 专注模式(F11):隐藏一切只剩文字 +- 多篇文档管理(新建 / 切换 / 删除),按更新时间排序 +- 本地自动保存(localStorage),关掉浏览器也不丢 +- 实时字数统计 + 写作计时 +- 导出 TXT / Markdown / 复制全文 +- 深色 / 浅色主题 +- 多端同步(WebDAV):内容经你的密码端到端加密后上传(网盘只存密文,无法读取) + +## 目录结构 +``` +src/ 源代码(网页、样式、JS、图标、服务器) +android/ Android 构建工程 +scripts/ 构建/安装脚本 +``` + +## 一键启动(推荐) +```bash +node src/start.js +# 浏览器访问 http://localhost:8080 +``` +该命令启动写作页面(8080),并内置 WebDAV 同步代理(同源 `/api/webdav/`)。 +可用 `PORT=9000 node src/start.js` 改端口。 + +## 仅本地写作(无需同步) +```bash +python -m http.server 8000 +# 浏览器访问 http://localhost:8000 +``` + +## 多端同步(WebDAV) +通过你自己的网盘(坚果云 / Nextcloud / 自建 WebDAV)做存储,内容端到端加密后上传,网盘只存密文、无法读取。 + +### 1. 启动服务 +用 `node src/start.js` 启动(已内置 WebDAV 代理,无需再单独跑其它服务)。 + +### 2. 在写作软件里登录 +点右上角 ☁ 按钮,填写 WebDAV 信息后点"登录并同步": +- **WebDAV 服务器地址**:如坚果云 `https://dav.jianguoyun.com/dav/`、Nextcloud `https://你的域名/remote.php/dav/files/用户名/` +- **存储路径**:文件名,默认 `jiandan-sync.json`(可带子目录,如 `notes/app.json`) +- **WebDAV 用户名 / 密码**:登录该网盘的账号密码 +- **加密密码**:内容端到端加密用的密码(与 WebDAV 密码可不同,请牢记) +- 刷新页面后会**自动恢复**并继续后台同步。 + +> 浏览器直连第三方 WebDAV 会被 CORS 拦截,因此 WebDAV 请求统一经本机 `src/start.js` 的 `/api/webdav/` 代理转发(代理只转发密文,不接触明文)。**所以使用 WebDAV 也必须通过 `node src/start.js` 启动**,不能直接用 `file://` 打开页面。 + +WebDAV 实时性:编辑后约 1.2 秒自动上传,并**每 8 秒轮询一次**远程变更(WebDAV 协议无推送能力,这是准实时方案)。 + +### 3. 多设备 +Windows 与安卓上填**同一 WebDAV 账号与同一加密密码**即可双向同步(按"最后修改时间"合并,最后写入获胜)。 + +## 跨网络 / 公网部署(真正异地多端) +局域网内用电脑 IP 即可;若要在不同网络(如公司/家里)同步,需把服务部署到有公网 IP 的机器,并用 **HTTPS**(浏览器只在 `localhost` 或 HTTPS 下才允许使用加密 API): + +- 最简单:用 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflared/) 或 `ngrok` 把本地端口暴露为 HTTPS 地址; +- 或放到任何云服务器 + 反向代理(Nginx/Caddy)+ 免费证书; +- 务必用 HTTPS 的地址填到软件里,否则加密同步会被浏览器禁用。 + +## 安全说明 +- 文档内容在浏览器内用 `PBKDF2` 派生的 `AES-GCM` 密钥加密后才上传,WebDAV 网盘仅存储密文。 +- 加密密码只存在于你的浏览器内存,不上传给任何人;网盘服务商 / 运维方均无法读取你的文字。 +- 但加密密码一旦遗忘,内容将无法恢复,请务必牢记。 +- WebDAV 账密由你的网盘保管;代理以 HTTPS 经同源转发,请尽量在可信网络或 HTTPS 下使用。 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..48354a3 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..50f2902 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,54 @@ +apply plugin: 'com.android.application' + +android { + namespace "com.chunchun.xiezuo" + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.chunchun.xiezuo" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..fdb4970 --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -0,0 +1,19 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import android.content.Context; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4d7ca38 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/chunchun/xiezuo/MainActivity.java b/android/app/src/main/java/com/chunchun/xiezuo/MainActivity.java new file mode 100644 index 0000000..9094499 --- /dev/null +++ b/android/app/src/main/java/com/chunchun/xiezuo/MainActivity.java @@ -0,0 +1,5 @@ +package com.chunchun.xiezuo; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/android/app/src/main/res/drawable-land-hdpi/splash.png b/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..e31573b Binary files /dev/null and b/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-mdpi/splash.png b/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xhdpi/splash.png b/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..8077255 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..14c6c8f Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..244ca25 Binary files /dev/null and b/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-hdpi/splash.png b/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..74faaa5 Binary files /dev/null and b/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-mdpi/splash.png b/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..e944f4a Binary files /dev/null and b/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xhdpi/splash.png b/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..564a82f Binary files /dev/null and b/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..bfabe68 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..6929071 Binary files /dev/null and b/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..f7a6492 Binary files /dev/null and b/android/app/src/main/res/drawable/splash.png differ diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..c023e50 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2127973 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..b441f37 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..72905b8 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ed0605 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9502e47 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4d1e077 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..df0f158 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..853db04 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6cdf97c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2960cbb Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8e3093a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..46de6e2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d2ea9ab Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a40d73e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..74f0cd9 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + 纯纯写作 + 纯纯写作 + com.chunchun.xiezuo + com.chunchun.xiezuo + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..0dc9f5b --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,34 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + maven { url 'https://maven.aliyun.com/repository/public' } + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.2.1' + classpath 'com.google.gms:google-services:4.4.0' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + maven { url 'https://maven.aliyun.com/repository/public' } + maven { url 'https://maven.aliyun.com/repository/google' } + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle new file mode 100644 index 0000000..9a5fa87 --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,3 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..99d578f --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m -Dhttps.protocols=TLSv1.2,TLSv1.3 -Djavax.net.ssl.trustStoreType=JKS + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +android.overridePathCheck=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c747538 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..fcb6fca --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/android/variables.gradle b/android/variables.gradle new file mode 100644 index 0000000..8ef305d --- /dev/null +++ b/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 22 + compileSdkVersion = 34 + targetSdkVersion = 34 + androidxActivityVersion = '1.8.0' + androidxAppCompatVersion = '1.6.1' + androidxCoordinatorLayoutVersion = '1.2.0' + androidxCoreVersion = '1.12.0' + androidxFragmentVersion = '1.6.2' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.9.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.1.5' + androidxEspressoCoreVersion = '3.5.1' + cordovaAndroidVersion = '10.1.1' +} \ No newline at end of file diff --git a/capacitor.config.json b/capacitor.config.json new file mode 100644 index 0000000..8cb901c --- /dev/null +++ b/capacitor.config.json @@ -0,0 +1,11 @@ +{ + "appId": "com.jiandan.xiezuo", + "appName": "简单写作", + "webDir": "src", + "server": { + "androidScheme": "https" + }, + "android": { + "allowMixedContent": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b731b8c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1260 @@ +{ + "name": "chunchun-xiezuo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chunchun-xiezuo", + "version": "1.0.0", + "devDependencies": { + "@capacitor/android": "^6.0.0", + "@capacitor/cli": "^6.0.0", + "@capacitor/core": "^6.0.0" + } + }, + "node_modules/@capacitor/android": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.1.tgz", + "integrity": "sha512-8gd4CIiQO5LAIlPIfd5mCuodBRxMMdZZEdj8qG8m+dQ1sQ2xyemVpzHmRK8qSCHorsBUCg3D62j2cp6bEBAkdw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^6.2.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-6.2.1.tgz", + "integrity": "sha512-JKl0FpFge8PgQNInw12kcKieQ4BmOyazQ4JGJOfEpVXlgrX1yPhSZTPjngupzTCiK3I7q7iGG5kjun0fDqgSCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.5", + "@ionic/utils-fs": "^3.1.6", + "@ionic/utils-subprocess": "2.1.11", + "@ionic/utils-terminal": "^2.3.3", + "commander": "^9.3.0", + "debug": "^4.3.4", + "env-paths": "^2.2.0", + "kleur": "^4.1.4", + "native-run": "^2.0.0", + "open": "^8.4.0", + "plist": "^3.0.5", + "prompts": "^2.4.2", + "rimraf": "^4.4.1", + "semver": "^7.3.7", + "tar": "^6.1.11", + "tslib": "^2.4.0", + "xml2js": "^0.5.0" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz", + "integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.5.tgz", + "integrity": "sha512-HD72a71IQVBmQckDwmA8RxNVMTbxnaLbgFOl+dO5tbvW9CkkSFCv41h6fUuNsSEVgngfkn0i98HDuZC8mk+lTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.5.tgz", + "integrity": "sha512-XnYNSwfewUqxq+yjER1hxTKggftpNjFLJH0s37jcrNDwbzmbpFTQTVAp4ikNK4rd9DOebX/jbeZb8jfD86IYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.10.tgz", + "integrity": "sha512-mZ7JEowcuGQK+SKsJXi0liYTcXd2bNMR3nE0CyTROpMECUpJeAvvaBaPGZf5ERQUPeWBVuwqAqjUmIdxhz5bxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.5", + "@ionic/utils-terminal": "2.3.3", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-process/node_modules/@ionic/utils-terminal": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.3.tgz", + "integrity": "sha512-RnuSfNZ5fLEyX3R5mtcMY97cGD1A0NVBbarsSQ6yMMfRJ5YHU7hHVyUfvZeClbqkBC/pAqI/rYJuXKCT9YeMCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.5.tgz", + "integrity": "sha512-hkm46uHvEC05X/8PHgdJi4l4zv9VQDELZTM+Kz69odtO9zZYfnt8DkfXHJqJ+PxmtiE5mk/ehJWLnn/XAczTUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-2.1.11.tgz", + "integrity": "sha512-6zCDixNmZCbMCy5np8klSxOZF85kuDyzZSTTQKQP90ZtYNCcPYmuFSzaqDwApJT4r5L3MY3JrqK1gLkc6xiUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.5", + "@ionic/utils-fs": "3.1.6", + "@ionic/utils-process": "2.1.10", + "@ionic/utils-stream": "3.1.5", + "@ionic/utils-terminal": "2.3.3", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-subprocess/node_modules/@ionic/utils-fs": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.6.tgz", + "integrity": "sha512-eikrNkK89CfGPmexjTfSWl4EYqsPSBh0Ka7by4F0PLc1hJZYtJxUZV3X4r5ecA8ikjicUmcbU7zJmAjmqutG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-subprocess/node_modules/@ionic/utils-terminal": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.3.tgz", + "integrity": "sha512-RnuSfNZ5fLEyX3R5mtcMY97cGD1A0NVBbarsSQ6yMMfRJ5YHU7hHVyUfvZeClbqkBC/pAqI/rYJuXKCT9YeMCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.3.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", + "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^9.2.0" + }, + "bin": { + "rimraf": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..42038d1 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "jiandan-xiezuo", + "version": "1.0.0", + "description": "简单写作 - 极简专注的跨平台写作工具", + "scripts": { + "start": "node src/start.js", + "cap:add": "npx cap add android", + "cap:sync": "npx cap sync", + "cap:open": "npx cap open android" + }, + "devDependencies": { + "@capacitor/android": "^6.0.0", + "@capacitor/cli": "^6.0.0", + "@capacitor/core": "^6.0.0" + } +} diff --git a/scripts/build_apk.bat b/scripts/build_apk.bat new file mode 100644 index 0000000..1d8b93c --- /dev/null +++ b/scripts/build_apk.bat @@ -0,0 +1,32 @@ +@echo off +chcp 65001 >nul +title Bote APK Builder +echo. +echo ======================================== +echo 波忒 Android APK 一键构建 +echo ======================================== +echo. + +set JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-17.0.17.10-hotspot +set ANDROID_HOME=C:\Android +set PATH=%JAVA_HOME%\bin;%PATH% + +echo [1/1] 正在编译 APK(首次约需5-10分钟)... +echo. + +cd /d "%~dp0..\android" +call gradlew.bat assembleDebug + +if exist "app\build\outputs\apk\debug\app-debug.apk" ( + echo. + echo ======================================== + echo 构建成功! + echo ======================================== + copy /y "app\build\outputs\apk\debug\app-debug.apk" "%USERPROFILE%\Desktop\bote.apk" >nul + echo APK 已复制到桌面: bote.apk + echo. +) else ( + echo. + echo 构建失败,请截图上方错误信息 +) +pause diff --git a/scripts/install_android_build.ps1 b/scripts/install_android_build.ps1 new file mode 100644 index 0000000..bbbfbf1 --- /dev/null +++ b/scripts/install_android_build.ps1 @@ -0,0 +1,45 @@ +# Android APK 构建环境一键安装脚本 +# 右键此文件 → 使用 PowerShell 运行,或在终端输入:powershell -File install_android_build.ps1 + +$ErrorActionPreference = "Stop" +$JDK_HOME = "C:\Program Files\Eclipse Adoptium\jdk-17.0.17.10-hotspot" +$ANDROID_HOME = "C:\Android" + +Write-Host "===== 1/3: 刷新环境变量 =====" -ForegroundColor Green +$env:JAVA_HOME = $JDK_HOME +$env:ANDROID_HOME = $ANDROID_HOME +$env:PATH = "$JDK_HOME\bin;$ANDROID_HOME\cmdline-tools\latest\bin;$env:PATH" +java -version + +Write-Host "===== 2/3: 安装 Android SDK 组件 =====" -ForegroundColor Green +# 接受 SDK 许可 +cmd /c "yes | `"$ANDROID_HOME\cmdline-tools\latest\bin\sdkmanager.bat`" --sdk_root=$ANDROID_HOME --licenses" 2>$null + +# 安装必要的 SDK 组件 +cmd /c "`"$ANDROID_HOME\cmdline-tools\latest\bin\sdkmanager.bat`" --sdk_root=$ANDROID_HOME platform-tools platforms;android-35 build-tools;35.0.0" + +Write-Host "===== 3/3: 构建 APK =====" -ForegroundColor Green +$projectDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location "$projectDir\..\android" + +$env:JAVA_HOME = $JDK_HOME +$env:ANDROID_HOME = $ANDROID_HOME +$env:PATH = "$JDK_HOME\bin;$ANDROID_HOME\cmdline-tools\latest\bin;$env:PATH" + +cmd /c "set JAVA_HOME=$JDK_HOME&& set ANDROID_HOME=$ANDROID_HOME&& set PATH=%JAVA_HOME%\bin;%ANDROID_HOME%\cmdline-tools\latest\bin;%PATH%&& gradlew assembleDebug" + +$apkPath = "$projectDir\..\android\app\build\outputs\apk\debug\app-debug.apk" +if (Test-Path $apkPath) { + Write-Host "===== APK 构建成功! =====" -ForegroundColor Green + Write-Host "APK 位置: $apkPath" -ForegroundColor Yellow + Write-Host "大小: $((Get-Item $apkPath).Length / 1MB) MB" -ForegroundColor Yellow + + # 复制到桌面方便找 + Copy-Item $apkPath "$env:USERPROFILE\Desktop\bote.apk" -Force + Write-Host "已复制到桌面: bote.apk" -ForegroundColor Yellow +} else { + Write-Host "构建失败,请检查上面的错误信息" -ForegroundColor Red +} + +Write-Host "按任意键退出..." -ForegroundColor Gray +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..11472c9 --- /dev/null +++ b/src/app.js @@ -0,0 +1,1000 @@ +/* ==================== 简单写作 · 核心逻辑 ==================== */ +(function () { + "use strict"; + + const STORE_KEY = "jiandan.docs.v1"; + const THEME_KEY = "jiandan.theme"; + const ACTIVE_KEY = "jiandan.active"; + const SETTINGS_KEY = "jiandan.settings"; + + const DEFAULT_SETTINGS = { theme: "system", font: 19, width: 920, fontFamily: "", paper: "", typewriter: true, goal: 0, focusCount: false, indent: 2, paraGap: 0.6, line: 1.8 }; + let settings = Object.assign({}, DEFAULT_SETTINGS); + try { + const s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}"); + settings = Object.assign({}, DEFAULT_SETTINGS, s); + } catch (e) {} + function saveSettings() { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } + + // ---------- DOM ---------- + const $ = (id) => document.getElementById(id); + const editor = $("editor"); + const titleInput = $("titleInput"); + const docList = $("docList"); + const wordCount = $("wordCount"); + const saveState = $("saveState"); + const timerEl = $("timer"); + const todayEl = $("todayCount"); + const sidebar = $("sidebar"); + const overlay = $("overlay"); + const exportMenu = $("exportMenu"); + + // ---------- contenteditable 文本模型 ---------- + // 正文以"每行一段"的纯文本为真值(段落间用单个 \n 分隔,不含空行); + // DOM 中每段渲染为一个
,段间距和段首缩进由 CSS 体现,不写入正文。 + function edGet() { + const out = []; + editor.childNodes.forEach((node) => { + if (node.nodeType === 3) { + out.push(node.nodeValue.replace(/\n/g, "")); + } else if (node.nodeName === "BR") { + out.push(""); + } else { + out.push(node.textContent); + } + }); + let text = out.join("\n"); + // 去掉末尾多余空行 + return text.replace(/\n+$/g, ""); + } + function makePara(line) { + const div = document.createElement("div"); + div.className = "para"; + if (line === "") div.appendChild(document.createElement("br")); + else div.textContent = line; + return div; + } + function edSet(text) { + editor.innerHTML = ""; + const lines = String(text).replace(/\r\n?/g, "\n").split("\n"); + for (let line of lines) { + // 清理旧数据中可能已有的段首缩进字符(缩进现在是纯 CSS) + line = line.replace(/^[  ]+/g, ""); + editor.appendChild(makePara(line)); + } + if (!editor.firstChild) editor.appendChild(makePara("")); + updateEmptyState(); + } + function updateEmptyState() { + editor.classList.toggle("is-empty", edGet().trim() === ""); + } + // 计算 caret 在某段落内的字符偏移 + function offsetWithin(root, node, offset) { + let count = 0, done = false; + (function walk(n) { + if (done) return; + if (n === node) { + if (n.nodeType === 3) count += offset; + else for (let i = 0; i < offset && n.childNodes[i]; i++) count += n.childNodes[i].textContent.length; + done = true; return; + } + if (n.nodeType === 3) count += n.nodeValue.length; + else for (const c of n.childNodes) { walk(c); if (done) return; } + })(root); + return count; + } + function setParaText(para, text) { + para.textContent = text; + if (text === "") para.appendChild(document.createElement("br")); + } + function setCaretInPara(para, offset) { + const sel = window.getSelection(); + const range = document.createRange(); + const texts = []; + (function collect(n) { if (n.nodeType === 3) texts.push(n); else n.childNodes.forEach(collect); })(para); + if (!texts.length) { range.setStart(para, 0); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); return; } + let remaining = offset, target = null, tOff = 0; + for (const t of texts) { + if (remaining <= t.nodeValue.length) { target = t; tOff = remaining; break; } + remaining -= t.nodeValue.length; + } + if (!target) { target = texts[texts.length - 1]; tOff = target.nodeValue.length; } + range.setStart(target, tOff); range.collapse(true); + sel.removeAllRanges(); sel.addRange(range); + } + function placeCaretEnd() { + const sel = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + sel.removeAllRanges(); sel.addRange(range); + } + function currentPara() { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return null; + let node = sel.getRangeAt(0).startContainer; + let para = node.nodeType === 3 ? node.parentNode : node; + while (para && para.parentNode !== editor) para = para.parentNode; + return para && para.parentNode === editor ? para : null; + } + // 在光标处插入纯文本(支持多行,用于粘贴) + function insertTextAtCaret(text) { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return; + const range = sel.getRangeAt(0); + range.deleteContents(); + const lines = String(text).replace(/\r\n?/g, "\n").split("\n"); + let node = range.startContainer, offset = range.startOffset; + let para = node.nodeType === 3 ? node.parentNode : node; + while (para && para.parentNode !== editor) para = para.parentNode; + if (!para || para.parentNode !== editor) { + const base = edGet(); + edSet((base ? base + "\n" : "") + lines.join("\n")); + placeCaretEnd(); + return; + } + const caretOffset = offsetWithin(para, node, offset); + const full = para.textContent; + const before = full.slice(0, caretOffset); + const after = full.slice(caretOffset); + if (lines.length === 1) { + setParaText(para, before + lines[0] + after); + setCaretInPara(para, (before + lines[0]).length); + } else { + setParaText(para, before + lines[0]); + let ref = para; + for (let i = 1; i < lines.length; i++) { + const content = i === lines.length - 1 ? lines[i] + after : lines[i]; + const div = makePara(content); + ref.after(div); + ref = div; + } + setCaretInPara(ref, lines[lines.length - 1].length); + } + } + + // ---------- 状态 ---------- + let docs = []; // {id, title, content, updated} + let activeId = null; + let saveTimer = null; + let writeSeconds = 0; + let lastTypeAt = 0; + let typewriterOn = localStorage.getItem("jiandan.typewriter") !== "off"; + + // ---------- 工具 ---------- + const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7); + const now = () => Date.now(); + + // 历史版本上限与最小间隔(避免编辑过程中产生过多碎片版本) + const HISTORY_MAX = 5; + const HISTORY_MIN_GAP = 120000; // 2 分钟 + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c])); + } + + function fmtDate(ts) { + const d = new Date(ts); + const pad = (n) => String(n).padStart(2, "0"); + const today = new Date(); + if (d.toDateString() === today.toDateString()) { + return `今天 ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + + function countWords(text) { + if (!text) return 0; + // 中文按字算,英文按词算 + const cn = (text.match(/[\u4e00-\u9fa5]/g) || []).length; + const en = (text.replace(/[\u4e00-\u9fa5]/g, " ").match(/[A-Za-z0-9]+/g) || []).length; + return cn + en; + } + function detailWords(text) { + text = text || ""; + const cn = (text.match(/[\u4e00-\u9fa5]/g) || []).length; + const en = (text.replace(/[\u4e00-\u9fa5]/g, " ").match(/[A-Za-z0-9]+/g) || []).length; + const para = text.trim() ? text.trim().split("\n").filter((l) => l.trim()).length : 0; + return { total: cn + en, cn, en, para }; + } + function todayKey() { + const d = new Date(); + return d.getFullYear() + "-" + (d.getMonth() + 1) + "-" + d.getDate(); + } + let todayCount = 0; + try { + const t = JSON.parse(localStorage.getItem("jiandan.daily") || "{}"); + todayCount = t.date === todayKey() ? (t.count || 0) : 0; + } catch (e) {} + function saveToday() { + localStorage.setItem("jiandan.daily", JSON.stringify({ date: todayKey(), count: todayCount })); + } + let lastCount = 0; // 当前文章上次字数,用于计算今日净增 + + // ---------- 存储(IndexedDB 为主,localStorage 镜像兜底) ---------- + // 文章与历史版本体积可能较大:localStorage 仅 ~5MB 且易被清理; + // IndexedDB 配额大(数十 MB~)、在 APK 的 WebView 中同样可靠。 + const DB_NAME = "jiandan-db"; + const DB_STORE = "kv"; + const DB_DOC_KEY = "docs"; + let _dbPromise = null; + let _useIDB = true; + + function openDB() { + if (!_useIDB) return Promise.reject(new Error("idb disabled")); + if (_dbPromise) return _dbPromise; + _dbPromise = new Promise((resolve, reject) => { + if (!("indexedDB" in window)) { _useIDB = false; reject(new Error("no idb")); return; } + let req; + try { req = indexedDB.open(DB_NAME, 1); } + catch (e) { _useIDB = false; reject(e); return; } + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(DB_STORE)) db.createObjectStore(DB_STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => { _useIDB = false; reject(req.error); }; + }); + return _dbPromise; + } + async function idbGet(key) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(DB_STORE, "readonly"); + const r = tx.objectStore(DB_STORE).get(key); + r.onsuccess = () => resolve(r.result); + r.onerror = () => reject(r.error); + }); + } + async function idbSet(key, val) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(DB_STORE, "readwrite"); + tx.objectStore(DB_STORE).put(val, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + // localStorage 同步镜像:崩溃/异常时的兜底(配额超限则忽略,以 IndexedDB 为准) + function lsMirrorSet(val) { + try { localStorage.setItem(STORE_KEY, JSON.stringify(val)); } catch (e) {} + } + + async function load() { + let idbDocs = null, lsDocs = null; + if (_useIDB) { try { idbDocs = await idbGet(DB_DOC_KEY); } catch (e) { idbDocs = null; } } + try { lsDocs = JSON.parse(localStorage.getItem(STORE_KEY) || "null"); } catch (e) { lsDocs = null; } + const a = Array.isArray(idbDocs) ? idbDocs : null; + const b = Array.isArray(lsDocs) ? lsDocs : null; + // 取两者中"更新时间更新"的一份作为真值(localStorage 镜像为同步写入、可能比异步 IDB 更新; + // 反之当 localStorage 因配额写入失败时,IDB 才是完整数据)。 + let stored = null; + if (a && b) { + const maxUpd = (arr) => arr.reduce((m, d) => Math.max(m, d.updated || 0), 0); + stored = maxUpd(a) >= maxUpd(b) ? a : b; + } else { + stored = a || b; + } + docs = Array.isArray(stored) ? stored : []; + // 迁移:旧版本用空行表示段间距;新模型下段间距为纯排版,正文内不留空行。 + // 将连续空行折叠为单个换行(幂等、安全),避免旧内容显示出双重间距。 + for (const d of docs) { + if (typeof d.content === "string") { + d.content = d.content.replace(/\r\n?/g, "\n").replace(/\n{2,}/g, "\n"); + } + } + activeId = localStorage.getItem(ACTIVE_KEY); + if (!docs.length) { + const d = newDocObj(); + docs.push(d); + activeId = d.id; + } + if (!docs.find((d) => d.id === activeId)) activeId = docs[0].id; + } + + function persist() { + localStorage.setItem(ACTIVE_KEY, activeId); + lsMirrorSet(docs); // 同步镜像,崩溃兜底 + if (_useIDB) idbSet(DB_DOC_KEY, docs).catch(() => {}); // 真值异步写入 IndexedDB + } + + function newDocObj() { + return { id: uid(), title: "", content: "", updated: now() }; + } + + function getActive() { + return docs.find((d) => d.id === activeId); + } + + // ---------- 渲染 ---------- + function renderList() { + docList.innerHTML = ""; + // 按更新时间倒序 + const sorted = [...docs].sort((a, b) => b.updated - a.updated); + for (const d of sorted) { + const item = document.createElement("div"); + item.className = "doc-item" + (d.id === activeId ? " active" : ""); + item.dataset.id = d.id; + + const title = document.createElement("div"); + title.className = "d-title"; + title.textContent = d.title.trim() || firstLine(d.content) || "无标题"; + + const meta = document.createElement("div"); + meta.className = "d-meta"; + meta.innerHTML = `${countWords(d.content)} 字${fmtDate(d.updated)}`; + + const del = document.createElement("button"); + del.className = "del-btn"; + del.textContent = "×"; + del.title = "删除"; + del.addEventListener("click", (e) => { + e.stopPropagation(); + deleteDoc(d.id); + }); + + item.appendChild(title); + item.appendChild(meta); + item.appendChild(del); + item.addEventListener("click", () => switchDoc(d.id)); + docList.appendChild(item); + } + } + + function firstLine(text) { + if (!text) return ""; + const line = text.split("\n").find((l) => l.trim()); + return line ? line.trim().slice(0, 20) : ""; + } + + function renderEditor() { + const d = getActive(); + if (!d) return; + titleInput.value = d.title; + edSet(d.content); + placeCaretEnd(); + updateWordCount(false); + typewriterScroll(); + } + + function updateWordCount(countToday = true) { + updateEmptyState(); + const det = detailWords(edGet()); + if (wordCount) wordCount.textContent = det.total + " 字"; + if (wordCount) wordCount.title = "中文字 " + det.cn + " · 英文词 " + det.en + " · 段落 " + det.para; + if (countToday && det.total > lastCount) { todayCount += det.total - lastCount; saveToday(); } + lastCount = det.total; + if (todayEl) todayEl.textContent = "今日 " + todayCount; + updateGoal(); + } + + // ---------- 文档操作 ---------- + function switchDoc(id) { + saveNow(); + activeId = id; + persist(); + renderEditor(); + renderList(); + closeSidebar(); + editor.focus(); + } + + function createDoc() { + saveNow(); + const d = newDocObj(); + docs.push(d); + activeId = d.id; + persist(); + renderEditor(); + renderList(); + closeSidebar(); + titleInput.focus(); + } + + function deleteDoc(id) { + const d = docs.find((x) => x.id === id); + const name = (d && (d.title.trim() || firstLine(d.content))) || "这篇文章"; + if (!confirm(`确定删除「${name}」吗?此操作不可恢复。`)) return; + docs = docs.filter((x) => x.id !== id); + if (activeId === id) { + if (!docs.length) docs.push(newDocObj()); + activeId = docs[0].id; + renderEditor(); + } + persist(); + renderList(); + } + + // ---------- 排版:段首缩进(纯 CSS text-indent,不写入正文) ---------- + // 中文排版习惯:以"段首缩进"区分段落,换行即新段落(而非靠空行)。因此每段视为一行。 + // 段间距、行间距与段首缩进均为纯排版属性,不写入正文;导出时再补上缩进字符。 + function applyTypo() { + const d = getActive(); + if (!d) return; + const raw = edGet(); + if (!raw.trim()) { if (saveState) saveState.textContent = "无需排版"; return; } + // 清理每行已有的首尾空白和全角缩进字符(幂等),过滤空行 + const paras = raw.replace(/\r\n?/g, "\n") + .split("\n") + .map((l) => l.replace(/^[ \t ]+/g, "").replace(/\s+$/g, "").trim()) + .filter((l) => l.length); + if (!paras.length) { if (saveState) saveState.textContent = "无需排版"; return; } + const body = paras.join("\n"); + edSet(body); + placeCaretEnd(); + updateWordCount(false); + saveNow(); + if (saveState) saveState.textContent = "已应用排版"; + editor.focus(); + } + + // ---------- 保存 ---------- + function markDirty() { + if (saveState) saveState.textContent = "编辑中…"; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(saveNow, 600); + } + + function saveNow() { + const d = getActive(); + if (!d) return; + d.title = titleInput.value; + d.content = edGet(); + d.updated = now(); + recordHistory(d); + persist(); + renderList(); + if (saveState) saveState.textContent = "已保存"; + if (window.__syncOnChange) window.__syncOnChange(); + } + + // ---------- 历史版本:随保存自动快照 ---------- + function recordHistory(d) { + if (!d.history) d.history = []; + const last = d.history[d.history.length - 1]; + if (last && last.content === d.content) return; // 内容无变化 + if (last && now() - last.t < HISTORY_MIN_GAP) return; // 距上次太近 + d.history.push({ t: now(), content: d.content }); + if (d.history.length > HISTORY_MAX) d.history = d.history.slice(d.history.length - HISTORY_MAX); + } + + // ---------- 计时 ---------- +setInterval(() => { + // 60 秒内有输入才算在写作 + if (now() - lastTypeAt < 60000) writeSeconds += 1; + const m = Math.floor(writeSeconds / 60); + if (timerEl) timerEl.textContent = `写作 ${m} 分钟`; +}, 1000); + + // ---------- 侧边栏 ---------- + function openSidebar() { + sidebar.classList.add("open"); + overlay.classList.add("show"); + } + function closeSidebar() { + sidebar.classList.remove("open"); + overlay.classList.remove("show"); + } + + // ---------- 主题 ---------- + function resolveTheme(pref) { + if (pref === "system") { + return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + return pref; + } + function applyThemePref(pref) { + localStorage.setItem(THEME_KEY, pref); + const t = resolveTheme(pref); + document.documentElement.setAttribute("data-theme", t); + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute("content", t === "dark" ? "#1b1a18" : "#f4efe6"); + const btn = $("themeBtn"); + if (btn) btn.textContent = t === "dark" ? "☀ 主题" : "🌙 主题"; + } + function toggleTheme() { + const cur = document.documentElement.getAttribute("data-theme"); + applyThemePref(cur === "dark" ? "light" : "dark"); + } + + // ---------- 设置应用 ---------- + function updateGoal() { + const goalEl = $("goalCount"); + if (!goalEl) return; + if (settings.goal > 0) { + goalEl.hidden = false; + const pct = Math.min(100, Math.round((todayCount / settings.goal) * 100)); + goalEl.textContent = `目标 ${todayCount}/${settings.goal}(${pct}%)`; + goalEl.title = `今日目标完成度 ${pct}%`; + } else { + goalEl.hidden = true; + } + } + function applySettings() { + applyThemePref(settings.theme); + editor.style.fontSize = settings.font + "px"; + editor.style.maxWidth = settings.width + "px"; + editor.style.fontFamily = settings.fontFamily || ""; + editor.style.background = settings.paper || ""; + editor.style.lineHeight = String(settings.line || 1.8); + // 段间距(纯排版):段间 margin = 段间距档位 × 行高(em 基于编辑区字号) + editor.style.setProperty("--para-gap", ((settings.paraGap || 0) * (settings.line || 1.8)) + "em"); + // 段首缩进(纯排版):CSS text-indent,不写入正文 + editor.style.setProperty("--para-indent", (settings.indent || 0) + "em"); + + typewriterOn = settings.typewriter; + localStorage.setItem("jiandan.typewriter", typewriterOn ? "on" : "off"); + $("typeBtn").classList.toggle("active", typewriterOn); + document.body.classList.toggle("show-count", !!settings.focusCount); + updateGoal(); + } + + + // ---------- 阅读模式 ---------- + function toggleReading() { + const isReading = document.body.classList.toggle("reading"); + if (isReading) { + editor.contentEditable = "false"; + } else { + editor.contentEditable = "true"; + editor.focus(); + } + } + + // ---------- 导出 ---------- + function download(filename, text) { + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + function safeName(name) { + return (name || "无标题").replace(/[\\/:*?"<>|]/g, "_").slice(0, 50); + } + + // 段间距(行高倍数)→ 导出时换算为空白行。缩进 → 导出时补上全角空格。 + // 将正文按换行分段,段间插入 round(paraGap) 个空行,段首补 indentStr。 + function formatParagraphs(text) { + const paras = String(text).replace(/\r\n?/g, "\n") + .split("\n") + .map((l) => l.replace(/\s+$/g, "")) + .filter((l) => l.trim().length); + const gap = Math.max(0, Math.round(settings.paraGap || 0)); + const sep = "\n" + "\n".repeat(gap); + const indentStr = " ".repeat(settings.indent || 0); + return paras.map((p) => indentStr + p).join(sep); + } + + function doExport(type) { + const d = getActive(); + if (!d) return; + const title = d.title.trim() || firstLine(d.content) || "无标题"; + const body = formatParagraphs(d.content); // 按段间距换算空白行 + if (type === "txt") { + download(safeName(title) + ".txt", (title ? title + "\n\n" : "") + body); + } else if (type === "md") { + download(safeName(title) + ".md", `# ${title}\n\n${body}`); + } else if (type === "copy") { + const text = (title ? title + "\n\n" : "") + body; + navigator.clipboard.writeText(text).then( + () => flash("已复制到剪贴板"), + () => flash("复制失败") + ); + } + exportMenu.hidden = true; + } + + function flash(msg) { + if (saveState) saveState.textContent = msg; + setTimeout(() => { if (saveState) saveState.textContent = "已保存"; }, 1500); + } + + // ---------- 打字机滚动(当前行居中) ---------- + // 直接用 Selection 的 Range 矩形定位光标,contenteditable 下更准确、无需镜像测量。 + function caretRect() { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return null; + const range = sel.getRangeAt(0).cloneRange(); + range.collapse(true); + let rect = range.getBoundingClientRect(); + if (!rect || (rect.top === 0 && rect.height === 0)) { + // 光标位于空段落等情形:退回当前段落块的位置 + let node = sel.anchorNode; + if (node && node.nodeType === 3) node = node.parentNode; + if (node && node.getBoundingClientRect) rect = node.getBoundingClientRect(); + } + return rect && rect.height ? rect : null; + } + function typewriterScroll() { + if (!typewriterOn) return; + const rect = caretRect(); + if (!rect) return; + const edRect = editor.getBoundingClientRect(); + const caretY = rect.top - edRect.top + editor.scrollTop; + const cs = getComputedStyle(editor); + const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 2; + const target = Math.max(0, caretY - editor.clientHeight / 2 + lh / 2); + editor.scrollTo({ top: target, behavior: "smooth" }); + } + + // ==================== 事件绑定 ==================== + editor.addEventListener("input", () => { + lastTypeAt = now(); + updateWordCount(); + markDirty(); + }); + + + titleInput.addEventListener("input", markDirty); + + // 重命名弹窗 + const renameMask = $("renameMask"); + const renameInput = $("renameInput"); + function openRename() { + renameInput.value = titleInput.value; + renameMask.hidden = false; + renameInput.focus(); + renameInput.select(); + } + function closeRename(save) { + if (save && renameInput.value.trim()) { + titleInput.value = renameInput.value.trim(); + markDirty(); + } + renameMask.hidden = true; + } + $("renameBtn").addEventListener("click", openRename); + $("renameConfirm").addEventListener("click", () => closeRename(true)); + $("renameCancel").addEventListener("click", () => closeRename(false)); + renameMask.addEventListener("click", (e) => { if (e.target === renameMask) closeRename(true); }); + renameInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); closeRename(true); } + if (e.key === "Escape") { e.preventDefault(); closeRename(false); } + }); + + $("newDocBtn").addEventListener("click", createDoc); + $("menuBtn").addEventListener("click", openSidebar); + overlay.addEventListener("click", closeSidebar); + $("themeBtn").addEventListener("click", toggleTheme); + $("focusBtn").addEventListener("click", toggleReading); + + $("typeBtn").addEventListener("click", () => { + typewriterOn = !typewriterOn; + settings.typewriter = typewriterOn; + saveSettings(); + localStorage.setItem("jiandan.typewriter", typewriterOn ? "on" : "off"); + $("typeBtn").classList.toggle("active", typewriterOn); + typewriterScroll(); + }); + editor.addEventListener("input", typewriterScroll); + editor.addEventListener("keyup", typewriterScroll); + editor.addEventListener("click", typewriterScroll); + + // 回车开始新段落:拆分当前段为两段,两段都自动补段首缩进(中文输入法选词 Enter 不触发) + editor.addEventListener("keydown", (e) => { + if (e.key !== "Enter" || e.shiftKey || e.isComposing) return; + e.preventDefault(); + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return; + const range = sel.getRangeAt(0); + range.deleteContents(); // 有选区则先删除 + const para = currentPara(); + if (!para) { + insertTextAtCaret("\n"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + return; + } + const node = sel.getRangeAt(0).startContainer; + const offset = sel.getRangeAt(0).startOffset; + const caretOffset = offsetWithin(para, node, offset); + const full = para.textContent; + const before = full.slice(0, caretOffset); + const after = full.slice(caretOffset); + setParaText(para, before); + const newPara = makePara(after.replace(/^[  ]+/g, "")); + para.after(newPara); + setCaretInPara(newPara, 0); + editor.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // 粘贴:只取纯文本,按换行拆分为段落,避免带入外部富文本样式 + editor.addEventListener("paste", (e) => { + e.preventDefault(); + const cd = e.clipboardData; + const text = cd ? cd.getData("text/plain") : ""; + if (!text) return; + insertTextAtCaret(text); + editor.dispatchEvent(new Event("input", { bubbles: true })); + }); + + $("exportBtn").addEventListener("click", (e) => { + e.stopPropagation(); + exportMenu.hidden = !exportMenu.hidden; + }); + exportMenu.querySelectorAll("button").forEach((b) => { + b.addEventListener("click", () => doExport(b.dataset.type)); + }); + document.addEventListener("click", (e) => { + if (!exportMenu.hidden && !exportMenu.contains(e.target) && e.target.id !== "exportBtn") { + exportMenu.hidden = true; + } + }); + + // ---------- 设置面板 ---------- + const settingsMask = $("settingsMask"); + function markPaperSwatch() { + document.querySelectorAll("#paperPicks .color-swatch").forEach((b) => { + b.classList.toggle("active", (b.dataset.color || "") === (settings.paper || "")); + }); + if (settings.paper) $("setPaperCustom").value = settings.paper; + } + function openSettings() { + $("setTheme").value = settings.theme; + $("setFont").value = String(settings.font); + $("setFontFamily").value = settings.fontFamily || $("setFontFamily").options[0].value; + $("setWidth").value = String(settings.width); + $("setIndent").value = String(settings.indent); + $("setParaGap").value = String(settings.paraGap); + $("setLine").value = String(settings.line); + $("setType").checked = settings.typewriter; + $("setGoal").value = settings.goal > 0 ? settings.goal : ""; + $("setFocusCount").checked = !!settings.focusCount; + markPaperSwatch(); + settingsMask.hidden = false; + } + function closeSettings() { settingsMask.hidden = true; } + function readSettingsFromUI() { + settings.theme = $("setTheme").value; + settings.font = parseInt($("setFont").value, 10) || 19; + settings.fontFamily = $("setFontFamily").value; + settings.width = parseInt($("setWidth").value, 10) || 920; + settings.indent = parseInt($("setIndent").value, 10) || 0; + settings.paraGap = parseFloat($("setParaGap").value) || 0; + settings.line = parseFloat($("setLine").value) || 1.8; + settings.typewriter = $("setType").checked; + settings.goal = parseInt($("setGoal").value, 10) || 0; + settings.focusCount = $("setFocusCount").checked; + saveSettings(); + applySettings(); + } + $("settingsBtn").addEventListener("click", (e) => { + e.stopPropagation(); + openSettings(); + }); + $("settingsClose").addEventListener("click", closeSettings); + settingsMask.addEventListener("click", (e) => { if (e.target === settingsMask) closeSettings(); }); + + // ---------- 历史记录面板 ---------- + const historyMask = $("historyMask"); + function openHistory() { + renderHistory(); + historyMask.hidden = false; + } + function closeHistory() { historyMask.hidden = true; } + function renderHistory() { + const d = getActive(); + const list = $("historyList"); + if (!list) return; + list.innerHTML = ""; + if (!d || !d.history || !d.history.length) { + list.innerHTML = '

暂无历史版本。继续写作,系统会每隔约 2 分钟自动保存一个版本。

'; + return; + } + // 最新在上 + const items = d.history.map((v, i) => ({ v, i })).reverse(); + for (const { v, i } of items) { + const item = document.createElement("div"); + item.className = "history-item"; + const words = countWords(v.content); + const snippet = v.content.replace(/\s+/g, " ").trim().slice(0, 60) || "(空)"; + item.innerHTML = + '
' + escapeHtml(fmtDate(v.t)) + '' + + '' + words + " 字
" + + '
' + escapeHtml(snippet) + "
" + + '
' + + '' + + '' + + '' + + "
" + + ''; + list.appendChild(item); + } + list.querySelectorAll(".h-preview").forEach((b) => b.addEventListener("click", () => { + const i = +b.dataset.i; + const pre = b.closest(".history-item").querySelector(".h-full"); + pre.hidden = !pre.hidden; + if (!pre.hidden) pre.textContent = d.history[i].content; + b.textContent = pre.hidden ? "预览" : "收起"; + })); + list.querySelectorAll(".h-restore").forEach((b) => b.addEventListener("click", () => restoreVersion(+b.dataset.i))); + list.querySelectorAll(".h-del").forEach((b) => b.addEventListener("click", () => { + if (confirm("删除此历史版本?不可恢复。")) deleteVersion(+b.dataset.i); + })); + } + function restoreVersion(idx) { + const d = getActive(); + if (!d || !d.history || !d.history[idx]) return; + if (!confirm("恢复此版本将覆盖当前正文。当前正文会自动存为一个历史版本,可随时找回。继续?")) return; + saveNow(); // 先确保当前编辑已落盘 + // 为当前正文额外留一个快照,便于反悔(避免与最新快照重复) + const last = d.history[d.history.length - 1]; + if (!last || last.content !== d.content) { + d.history.push({ t: now(), content: d.content }); + if (d.history.length > HISTORY_MAX) d.history = d.history.slice(d.history.length - HISTORY_MAX); + } + edSet(d.history[idx].content); + placeCaretEnd(); + updateWordCount(false); + saveNow(); + renderHistory(); + flash("已恢复到所选版本"); + } + function deleteVersion(idx) { + const d = getActive(); + if (!d || !d.history || !d.history[idx]) return; + d.history.splice(idx, 1); + persist(); + renderHistory(); + } + $("historyBtn").addEventListener("click", (e) => { e.stopPropagation(); openHistory(); }); + $("historyClose").addEventListener("click", closeHistory); + historyMask.addEventListener("click", (e) => { if (e.target === historyMask) closeHistory(); }); + ["setTheme", "setFont", "setFontFamily", "setWidth", "setType", "setGoal", "setFocusCount"].forEach((id) => { + const el = $(id); + el.addEventListener("change", readSettingsFromUI); + }); + // 段首缩进:纯 CSS text-indent,同时清理旧数据中的缩进字符 + function onIndentChange() { + settings.indent = parseInt($("setIndent").value, 10) || 0; + saveSettings(); + applySettings(); // 更新 --para-indent CSS 变量 + applyTypo(); // 清理旧数据中的缩进字符 + } + // 段间距:纯排版属性,仅更新 CSS 段间 margin,不改动正文 + function onParaGapChange() { + settings.paraGap = parseFloat($("setParaGap").value) || 0; + saveSettings(); + applySettings(); + } + function onLineChange() { + settings.line = parseFloat($("setLine").value) || 1.8; + saveSettings(); + applySettings(); + } + $("setIndent").addEventListener("change", onIndentChange); + $("setParaGap").addEventListener("change", onParaGapChange); + $("setLine").addEventListener("change", onLineChange); + $("applyTypoBtn").addEventListener("click", applyTypo); + // 输入区颜色:预设色块 + document.querySelectorAll("#paperPicks .color-swatch").forEach((b) => { + b.addEventListener("click", () => { + settings.paper = b.dataset.color || ""; + saveSettings(); + applySettings(); + markPaperSwatch(); + }); + }); + // 输入区颜色:自定义取色 + $("setPaperCustom").addEventListener("input", () => { + settings.paper = $("setPaperCustom").value; + saveSettings(); + applySettings(); + markPaperSwatch(); + }); + // 跟随系统:系统主题变化时实时刷新 + if (window.matchMedia) { + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { + if ((localStorage.getItem(THEME_KEY) || "system") === "system") applyThemePref("system"); + }); + } + + // 快捷键 + document.addEventListener("keydown", (e) => { + // Ctrl/Cmd + S 保存 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + saveNow(); + flash("已保存"); + } + // Ctrl/Cmd + N 新建 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "n") { + e.preventDefault(); + createDoc(); + } + // Esc 退出阅读模式 + if (e.key === "Escape" && document.body.classList.contains("reading")) { + document.body.classList.remove("reading"); + editor.contentEditable = "true"; + editor.focus(); + } + }); + + // 退出前保存 + window.addEventListener("beforeunload", saveNow); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") saveNow(); + }); + + // 阅读模式退出按钮 + const exitBtn = document.createElement("div"); + exitBtn.className = "reading-exit"; + exitBtn.textContent = "退出阅读 (Esc)"; + exitBtn.addEventListener("click", () => { + document.body.classList.remove("reading"); + editor.contentEditable = "true"; + editor.focus(); + }); + document.body.appendChild(exitBtn); + + // 阅读模式下:鼠标/手指移到顶部才显示退出按钮 + let readingHideTimer = null; + function onReadingTopMove(e) { + if (!document.body.classList.contains("reading")) return; + const y = e.touches ? e.touches[0].clientY : e.clientY; + if (y < 64) { + document.body.classList.add("top-shown"); + clearTimeout(readingHideTimer); + readingHideTimer = setTimeout(() => { + if (document.body.classList.contains("reading")) { + document.body.classList.remove("top-shown"); + } + }, 2500); + } + } + document.addEventListener("mousemove", onReadingTopMove); + document.addEventListener("touchstart", onReadingTopMove, { passive: true }); + + // ---------- 初始化 ---------- + applySettings(); + load().then(() => { + renderEditor(); + renderList(); + editor.focus(); + }); + + // ---------- PWA 安装 ---------- + let deferredPrompt = null; + const installBtn = $("installBtn"); + window.addEventListener("beforeinstallprompt", (e) => { + e.preventDefault(); + deferredPrompt = e; + installBtn.hidden = false; + }); + installBtn.addEventListener("click", async () => { + if (!deferredPrompt) return; + deferredPrompt.prompt(); + await deferredPrompt.userChoice; + deferredPrompt = null; + installBtn.hidden = true; + }); + window.addEventListener("appinstalled", () => (installBtn.hidden = true)); + + // 暴露接口给同步模块 (sync.js) + window.JianDan = { + getDocs: () => docs, + setDocs: (d) => { docs = d; }, + persist, + renderList, + renderEditor, + fmtDate, + }; + window.__syncOnChange = () => { + if (window.JianDanSync) window.JianDanSync.scheduleSync(); + }; + + // 打字机滚动初始化 + $("typeBtn").classList.toggle("active", typewriterOn); + requestAnimationFrame(typewriterScroll); + + // ---------- Service Worker ---------- + if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("sw.js").catch(() => {}); + }); + } +})(); diff --git a/src/icon-192.png b/src/icon-192.png new file mode 100644 index 0000000..d31df5e Binary files /dev/null and b/src/icon-192.png differ diff --git a/src/icon-512.png b/src/icon-512.png new file mode 100644 index 0000000..3126aee Binary files /dev/null and b/src/icon-512.png differ diff --git a/src/icon.svg b/src/icon.svg new file mode 100644 index 0000000..0a0905d --- /dev/null +++ b/src/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..0691d68 --- /dev/null +++ b/src/index.html @@ -0,0 +1,267 @@ + + + + + + + + 简单写作 + + + + + + + + + + + + +
+ + +
+
+ + + +
+ + + + + + +
+
+ + + + +
+
+
+ +
+ + + + + + + + + + + + + + + + diff --git a/src/manifest.json b/src/manifest.json new file mode 100644 index 0000000..c183e42 --- /dev/null +++ b/src/manifest.json @@ -0,0 +1,32 @@ +{ + "name": "简单写作", + "short_name": "简单写作", + "description": "极简专注的跨平台写作工具", + "start_url": "./index.html", + "scope": "./", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#f4efe6", + "theme_color": "#f4efe6", + "lang": "zh-CN", + "icons": [ + { + "src": "icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/src/start.js b/src/start.js new file mode 100644 index 0000000..e20bbb5 --- /dev/null +++ b/src/start.js @@ -0,0 +1,157 @@ +/* 简单写作 · 一站式启动器 + * 提供:静态写作页面 + WebDAV 同步代理(同源 /api/webdav/,绕过浏览器 CORS) + * 用法: node start.js (默认端口 8080,可用 PORT 覆盖) + */ +const http = require("http"); +const https = require("https"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 8080; +const ROOT = __dirname; + +// ===================== WebDAV 代理(绕过浏览器 CORS,仅转发密文) ===================== +function wdReq(targetBase, p, user, pass, method, body, extraHeaders) { + return new Promise((resolve, reject) => { + const base = targetBase.replace(/\/$/, ""); + const full = p ? base + "/" + String(p).replace(/^\//, "") : base; + let target; + try { target = new URL(full); } catch (e) { return reject(new Error("WebDAV 地址无效")); } + const auth = "Basic " + Buffer.from(user + ":" + pass, "utf8").toString("base64"); + const mod = target.protocol === "https:" ? https : http; + const opt = { + method, + hostname: target.hostname, + port: target.port || (target.protocol === "https:" ? 443 : 80), + path: (target.pathname || "/") + (target.search || ""), + headers: Object.assign({ Authorization: auth }, extraHeaders || {}), + }; + const req = mod.request(opt, (resp) => { + let data = ""; + resp.on("data", (c) => (data += c)); + resp.on("end", () => resolve({ status: resp.statusCode, headers: resp.headers, body: data })); + }); + req.on("error", reject); + if (body != null) req.write(body); + req.end(); + }); +} + +async function wdMkcolRecursive(base, dir, user, pass) { + const parts = String(dir).split("/").filter(Boolean); + let cur = base.replace(/\/$/, ""); + for (const part of parts) { + cur = cur + "/" + part; + try { await wdReq(cur, "", user, pass, "MKCOL"); } catch (e) { /* 已存在则忽略 */ } + } +} + +function wdParseProp(xml, tag) { + const m = xml.match(new RegExp(tag + "\\b[^>]*>([^<]*)<", "i")); + return m ? m[1].trim() : null; +} + +// 解析 PROPFIND (Depth:1) 的 multistatus,取出所有 href(忽略命名空间前缀差异) +function wdParseHrefs(xml) { + const out = []; + const re = /<[^>]*:?href[^>]*>([^<]+)<\/[^>]*:?href>/gi; + let m; + while ((m = re.exec(xml)) !== null) { + let h = m[1].trim(); + try { h = decodeURIComponent(h); } catch (e) { /* 保留原样 */ } + out.push(h); + } + return out; +} + +function sendWebdavJson(res, code, obj) { + res.writeHead(code, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }); + res.end(JSON.stringify(obj)); +} + +function handleWebdav(req, res, url) { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", async () => { + let json = {}; + try { if (body) json = JSON.parse(body); } catch { return sendWebdavJson(res, 400, { error: "请求格式错误" }); } + const { base, path: p, user, pass } = json; + if (!base || !user) return sendWebdavJson(res, 400, { error: "缺少 WebDAV 服务器地址或用户名" }); + const ep = url.pathname.replace("/api/webdav/", ""); + try { + if (ep === "propfind") { + const xml = '' + + ''; + const r = await wdReq(base, p, user, pass, "PROPFIND", xml, { Depth: "0", "Content-Type": "application/xml" }); + if (r.status === 404) return sendWebdavJson(res, 404, { status: 404 }); + return sendWebdavJson(res, 200, { + status: r.status, + etag: r.headers["etag"] || wdParseProp(r.body, "getetag"), + lastmodified: r.headers["last-modified"] || wdParseProp(r.body, "getlastmodified"), + }); + } + if (ep === "get") { + const r = await wdReq(base, p, user, pass, "GET"); + return sendWebdavJson(res, r.status, { status: r.status, etag: r.headers["etag"], content: r.body }); + } + if (ep === "list") { + // 列目录(Depth:1),返回目录下所有 href,供前端筛选备份存档 + const xml = '' + + ''; + const r = await wdReq(base, p, user, pass, "PROPFIND", xml, { Depth: "1", "Content-Type": "application/xml" }); + if (r.status === 404) return sendWebdavJson(res, 200, { status: 404, hrefs: [] }); + return sendWebdavJson(res, 200, { status: r.status, hrefs: wdParseHrefs(r.body) }); + } + if (ep === "put") { + const dir = String(p || "").includes("/") ? p.substring(0, p.lastIndexOf("/")) : ""; + if (dir) await wdMkcolRecursive(base, dir, user, pass); + const r = await wdReq(base, p, user, pass, "PUT", json.content || "", { "Content-Type": "application/json" }); + return sendWebdavJson(res, 200, { status: r.status, etag: r.headers["etag"] }); + } + return sendWebdavJson(res, 404, { error: "未知端点" }); + } catch (e) { + return sendWebdavJson(res, 502, { error: String((e && e.message) || e) }); + } + }); +} + +// ===================== 静态文件 ===================== +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".webmanifest": "application/manifest+json", + ".ico": "image/x-icon", +}; + +function serveStatic(req, res, url) { + let pathname = decodeURIComponent(url.pathname); + if (pathname === "/" || pathname === "") pathname = "/index.html"; + const filePath = path.normalize(path.join(ROOT, pathname)); + if (!filePath.startsWith(ROOT)) { res.writeHead(403); return res.end("Forbidden"); } + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + return res.end("404 Not Found"); + } + const ext = path.extname(filePath).toLowerCase(); + res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Cache-Control": "no-cache" }); + fs.createReadStream(filePath).pipe(res); +} + +// ===================== 路由 ===================== +const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname.startsWith("/api/webdav/")) { handleWebdav(req, res, url); return; } + serveStatic(req, res, url); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`[简单写作] 已启动 → http://localhost:${PORT}`); + console.log(`[简单写作] WebDAV 代理 → http://localhost:${PORT}/api/webdav/`); +}); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..7ed7f14 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,705 @@ +:root { + --bg: #f4efe6; + --paper: #fbf8f1; + --text: #2f2a24; + --text-soft: #8a8175; + --line: #e5ddcf; + --accent: #b9925a; + --sidebar-bg: #efe8db; + --hover: #e9e1d2; + --shadow: 0 2px 12px rgba(80, 60, 30, 0.06); + --font-ui: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; + --font-write: "Georgia", "Songti SC", "SimSun", serif; +} + +[data-theme="dark"] { + --bg: #1b1a18; + --paper: #232220; + --text: #e6e1d6; + --text-soft: #8f887b; + --line: #34322e; + --accent: #c9a15f; + --sidebar-bg: #201f1d; + --hover: #2c2a27; + --shadow: 0 2px 14px rgba(0, 0, 0, 0.3); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +/* hidden 属性必须始终生效,避免被 display 规则覆盖(如设置面板的 flex) */ +[hidden] { display: none !important; } + +html, body { + height: 100%; + overflow: hidden; +} + +body { + display: flex; + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + -webkit-font-smoothing: antialiased; + transition: background 0.3s, color 0.3s; +} + +/* ---------- 侧边栏 ---------- */ +.sidebar { + width: 260px; + flex-shrink: 0; + background: var(--sidebar-bg); + border-right: 1px solid var(--line); + display: flex; + flex-direction: column; + transition: transform 0.28s ease, width 0.28s ease; + z-index: 30; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 16px 12px; +} + +.brand { + font-size: 17px; + font-weight: 700; + letter-spacing: 1px; +} + +.doc-list { + flex: 1; + overflow-y: auto; + padding: 4px 8px; +} + +.doc-item { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + margin-bottom: 2px; + transition: background 0.15s; + position: relative; +} +.doc-item:hover { background: var(--hover); } +.doc-item.active { background: var(--hover); } +.doc-item.active::before { + content: ""; + position: absolute; + left: 0; top: 20%; + height: 60%; + width: 3px; + border-radius: 2px; + background: var(--accent); +} + +.doc-item .d-title { + font-size: 14px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.doc-item .d-meta { + font-size: 12px; + color: var(--text-soft); + margin-top: 3px; + display: flex; + justify-content: space-between; +} +.doc-item .del-btn { + position: absolute; + right: 8px; top: 8px; + border: none; background: transparent; + color: var(--text-soft); + cursor: pointer; + opacity: 0; + font-size: 16px; + line-height: 1; + padding: 2px 6px; + border-radius: 5px; +} +.doc-item:hover .del-btn { opacity: 1; } +.doc-item .del-btn:hover { background: var(--line); color: #c0392b; } + +.sidebar-footer { + padding: 12px 16px; + border-top: 1px solid var(--line); + display: flex; + gap: 8px; +} + +/* ---------- 主区 ---------- */ +.main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + position: relative; +} + +.topbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border-bottom: 1px solid var(--line); +} + +.menu-btn-wrap { display: none; } + +.title-input { + flex: 1; + visibility: hidden; + border: none; + background: transparent; + font-size: 17px; + font-weight: 600; + font-family: var(--font-write); + min-width: 0; +} + +.topbar-actions { display: flex; gap: 4px; } + +/* 重命名按钮默认隐藏,仅移动端显示 */ +.rename-btn { display: none; } + +.icon-btn { + border: none; + background: transparent; + color: var(--text-soft); + cursor: pointer; + padding: 7px; + border-radius: 8px; + display: inline-flex; + align-items: center; + transition: background 0.15s, color 0.15s; +} +.icon-btn:hover { background: var(--hover); color: var(--text); } +.icon-btn.active { color: var(--accent); background: var(--hover); } + +/* 同步按钮右下角的轻量绿色圆点(上传成功后显示) */ +#syncBtn { position: relative; } +.sync-badge { + position: absolute; + right: 4px; + bottom: 4px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #2ecc71; + box-shadow: 0 0 0 2px var(--paper); + pointer-events: none; +} +.sync-badge[hidden] { display: none !important; } + +.text-btn { + border: none; + background: transparent; + color: var(--text-soft); + cursor: pointer; + font-size: 13px; + padding: 6px 8px; + border-radius: 6px; + transition: background 0.15s, color 0.15s; +} +.text-btn:hover { background: var(--hover); color: var(--text); } + +/* ---------- 编辑区 ---------- */ +.editor-wrap { + flex: 1; + overflow: hidden; + display: flex; + justify-content: center; + position: relative; +} + +/* 编辑区 */ + +.editor { + width: 100%; + max-width: 920px; + height: 100%; + overflow-y: auto; + border: none; + outline: none; + resize: none; + background: var(--paper); + color: var(--text); + font-family: var(--font-write); + font-size: 19px; + line-height: 2; + padding: 48px 16px 40vh; + letter-spacing: 0.3px; + scroll-behavior: smooth; + /* contenteditable:保留空白与换行、长词换行、竖排书写方向常规 */ + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + position: relative; + -webkit-user-modify: read-write-plaintext-only; /* 尽量按纯文本编辑,减少富文本粘贴 */ +} +/* 段落块:段间距 = 段间距档位 × 行高(--para-gap,纯排版、不写入正文) */ +/* 段首缩进也通过 CSS text-indent 实现,不写入正文 */ +.editor .para { margin: 0; text-indent: var(--para-indent, 0); } +.editor .para + .para { margin-top: var(--para-gap, 0); } +/* 占位符(内容为空时显示) */ +.editor.is-empty::before { + content: attr(data-placeholder); + color: var(--text-soft); + position: absolute; + pointer-events: none; +} + +/* 编辑区滚动条:细、不预留空间、不用时透明,悬停/聚焦/拖动时悬浮显示 */ +.editor { scrollbar-width: thin; scrollbar-color: transparent transparent; } +.editor::-webkit-scrollbar { width: 8px; } +.editor::-webkit-scrollbar-track { background: transparent; } +.editor::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 8px; + border: 2px solid transparent; + background-clip: padding-box; +} +.editor:hover::-webkit-scrollbar-thumb, +.editor:focus::-webkit-scrollbar-thumb, +.editor:active::-webkit-scrollbar-thumb { + background-color: var(--line); +} +.editor::-webkit-scrollbar-thumb:hover { + background-color: var(--text-soft); +} +/* 深色主题下略微调亮,保持可见但依旧克制 */ +[data-theme="dark"] .editor:hover::-webkit-scrollbar-thumb, +[data-theme="dark"] .editor:focus::-webkit-scrollbar-thumb, +[data-theme="dark"] .editor:active::-webkit-scrollbar-thumb { background-color: #4a463f; } +[data-theme="dark"] .editor::-webkit-scrollbar-thumb:hover { background-color: var(--text-soft); } + +/* ---------- 导出菜单 ---------- */ +.export-menu { + position: absolute; + right: 18px; + top: 52px; + background: var(--paper); + border: 1px solid var(--line); + border-radius: 10px; + box-shadow: var(--shadow); + z-index: 40; + overflow: hidden; + min-width: 160px; +} +.export-menu button { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + color: var(--text); + padding: 11px 16px; + font-size: 14px; + cursor: pointer; +} +.export-menu button:hover { background: var(--hover); } + +/* ---------- 遮罩 ---------- */ +.overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.35); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s; + z-index: 25; +} +.overlay.show { opacity: 1; pointer-events: auto; } + +/* ---------- 阅读模式 ---------- */ +body.reading .sidebar, +body.reading .topbar { + opacity: 0; + pointer-events: none; +} +body.reading .sidebar { transform: translateX(-100%); } +body.reading .topbar { + height: 0; + padding-top: 0; + padding-bottom: 0; + border: none; + overflow: hidden; +} +body.reading .editor { + padding-top: 12vh; + cursor: default; + -webkit-user-select: text; + user-select: text; +} +body.reading .topbar, +body.reading .sidebar { transition: all 0.3s; } + +/* 阅读模式下右上角悬浮退出提示 */ +.reading-exit { + position: fixed; + top: 14px; right: 18px; + z-index: 50; + font-size: 12px; + color: var(--text-soft); + background: var(--paper); + border: 1px solid var(--line); + padding: 6px 12px; + border-radius: 20px; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + cursor: pointer; +} +/* 阅读模式下默认隐藏,鼠标/手指移到顶部才浮现 */ +body.reading .reading-exit { opacity: 0; pointer-events: none; } +body.reading.top-shown .reading-exit { opacity: 0.75; pointer-events: auto; } +.reading-exit:hover { opacity: 1 !important; } + +/* ---------- 移动端 ---------- */ +@media (max-width: 720px) { + .sidebar { + position: fixed; + top: 0; left: 0; bottom: 0; + transform: translateX(-100%); + box-shadow: var(--shadow); + } + .sidebar.open { transform: translateX(0); } + .editor { + font-size: 17px; + padding: 42px 0 40vh 16px; /* top 42px 为固定顶栏留空 */ + line-height: 1.9; + } + /* 顶栏始终显示,不隐藏 */ + .topbar { + padding: 6px 8px; + gap: 4px; + border-bottom: 1px solid var(--line); + } + /* 移动端隐藏标题框,仅弹窗方式修改 */ + .title-input { display: none; } + /* 移动端显示重命名按钮 */ + .rename-btn { display: inline-flex; } +} + +@media (min-width: 721px) { + #menuBtn { display: none; } +} +/* 大屏显示器:进一步放宽输入宽度 */ +@media (min-width: 1400px) { + .editor { max-width: 1100px; } +} +@media (min-width: 2000px) { + .editor { max-width: 1280px; } +} + +/* ---------- 重命名弹窗 ---------- */ +.rename-mask { + position: fixed; + inset: 0; + z-index: 100; + background: rgba(0,0,0,0.4); + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s; +} +.rename-dialog { + background: var(--paper); + border-radius: 12px; + padding: 24px 20px 16px; + width: min(360px, 90vw); + box-shadow: var(--shadow), 0 8px 30px rgba(0,0,0,0.12); + animation: slideUp 0.25s ease; +} +.rename-dialog-header { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + color: var(--text); +} +.rename-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-size: 16px; + font-family: var(--font-write); + outline: none; + box-sizing: border-box; +} +.rename-input:focus { border-color: var(--accent); } +.rename-dialog-btns { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } + +/* ---------- 同步面板 ---------- */ +.sync-panel { + position: absolute; + right: 18px; + top: 52px; + width: 300px; + max-width: calc(100vw - 36px); + background: var(--paper); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: var(--shadow); + z-index: 40; + overflow: hidden; + font-size: 14px; +} +.sync-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + border-bottom: 1px solid var(--line); + font-weight: 600; +} +.sync-panel-body { padding: 14px; } +.sync-panel label { + display: block; + font-size: 12px; + color: var(--text-soft); + margin: 10px 0 4px; +} +.sync-panel input { + width: 100%; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-size: 14px; + outline: none; +} +.sync-panel input:focus { border-color: var(--accent); } +.sync-panel select { + width: 100%; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-size: 14px; + outline: none; + margin-bottom: 2px; +} +.sync-panel select:focus { border-color: var(--accent); } +.sync-btns { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 14px; +} +.text-btn.primary { + background: var(--accent); + color: #fff; + padding: 8px 14px; + border-radius: 8px; +} +.text-btn.primary:hover { filter: brightness(1.05); color: #fff; background: var(--accent); } +.switch-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text); + margin-top: 6px; +} +.switch-row input { width: auto; } +.sync-user { font-size: 13px; margin-bottom: 4px; } +.sync-status { + font-size: 12px; + color: var(--accent); + margin-top: 12px; + min-height: 16px; +} +.sync-tip { + font-size: 11px; + color: var(--text-soft); + margin-top: 10px; + line-height: 1.5; +} + +/* ---------- 手动备份存档 ---------- */ +.backup-box { + margin-top: 14px; + padding-top: 12px; + border-top: 1px dashed var(--line); +} +.backup-title { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.backup-hint { + font-size: 11px; + font-weight: 400; + color: var(--text-soft); +} +.backup-btns { margin-top: 10px; justify-content: flex-start; } + +/* ---------- 设置面板 ---------- */ +.settings-mask { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 60; +} +.settings-panel { + width: 360px; + max-width: calc(100vw - 32px); + max-height: 86vh; + overflow-y: auto; + background: var(--paper); + border: 1px solid var(--line); + border-radius: 14px; + box-shadow: var(--shadow); +} +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + border-bottom: 1px solid var(--line); + font-weight: 600; +} +.settings-body { padding: 4px 16px 14px; } +.set-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--line); + font-size: 14px; +} +.set-row:last-child { border-bottom: none; } +.set-row > span { color: var(--text); } +.set-row select, +.set-row input[type="number"] { + min-width: 120px; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-size: 14px; + outline: none; +} +.set-row select:focus, +.set-row input:focus { border-color: var(--accent); } +.set-row input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); + cursor: pointer; +} + +/* ---------- 历史记录面板 ---------- */ +.history-tip { + margin: 10px 2px 4px; + font-size: 12.5px; + line-height: 1.6; + color: var(--text-soft); +} +.history-list { padding: 6px 0 4px; } +.history-empty { + font-size: 13px; + line-height: 1.7; + color: var(--text-soft); + padding: 18px 4px; +} +.history-item { + padding: 11px 0; + border-bottom: 1px solid var(--line); +} +.history-item:last-child { border-bottom: none; } +.h-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} +.h-time { font-size: 13.5px; font-weight: 600; color: var(--text); } +.h-words { font-size: 12px; color: var(--text-soft); flex-shrink: 0; } +.h-snippet { + margin: 5px 0 8px; + font-size: 12.5px; + line-height: 1.55; + color: var(--text-soft); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.h-actions { display: flex; gap: 6px; } +.h-actions .text-btn { + padding: 4px 12px; + font-size: 12.5px; + border: 1px solid var(--line); +} +.h-full { + margin: 9px 0 0; + padding: 10px 12px; + max-height: 240px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-write); + font-size: 13px; + line-height: 1.6; + color: var(--text); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 8px; +} +#goalCount { + color: var(--accent); + font-weight: 600; +} + +/* 输入区颜色选择 */ +.color-picks { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} +.color-swatch { + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid var(--line); + cursor: pointer; + padding: 0; + transition: transform 0.15s, box-shadow 0.15s; +} +.color-swatch:hover { transform: scale(1.12); } +.color-swatch.active { + box-shadow: 0 0 0 2px var(--paper), 0 0 0 4px var(--accent); +} +#setPaperCustom { + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--bg); + cursor: pointer; +} + + diff --git a/src/sw.js b/src/sw.js new file mode 100644 index 0000000..686bc18 --- /dev/null +++ b/src/sw.js @@ -0,0 +1,45 @@ +/* 简单写作 · Service Worker(离线缓存,network-first) */ +const CACHE = "jiandan-v1"; +const ASSETS = [ + "./", + "./index.html", + "./styles.css", + "./app.js", + "./manifest.json", + "./icon.svg" +]; + +self.addEventListener("install", (e) => { + e.waitUntil( + caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (e) => { + if (e.request.method !== "GET") return; + // 同步 API 不走缓存,直接请求 + if (e.request.url.includes("/api/")) { + e.respondWith(fetch(e.request)); + return; + } + // 其余资源:先请求网络(保证更新即时),失败再回退缓存(离线可用) + e.respondWith( + fetch(e.request) + .then((res) => { + if (res && res.status === 200 && res.type === "basic") { + const clone = res.clone(); + caches.open(CACHE).then((c) => c.put(e.request, clone)); + } + return res; + }) + .catch(() => caches.match(e.request)) + ); +}); diff --git a/src/sync.js b/src/sync.js new file mode 100644 index 0000000..1d4dd11 --- /dev/null +++ b/src/sync.js @@ -0,0 +1,371 @@ +/* ==================== 简单写作 · WebDAV 同步(前端) ==================== */ +/* 内容在浏览器端用 AES-GCM 端到端加密,经本机后端 /api/webdav/ 代理转发到 WebDAV(代理不接触明文)。 + * 浏览器直连第三方 WebDAV 会被 CORS 拦截,因此统一经同源代理。 + */ +(function () { + "use strict"; + const CC = () => window.JianDan; + const SYNC_KEY = "jiandan.syncCfg"; + + let cfg = loadCfg(); // { webdav:{base,path,user,pass,crypto}, lastSync, auto } + if (cfg.auto === undefined) cfg.auto = true; // 默认开启自动上传 + let state = { token: null, key: null, loggedIn: false, username: "", wd: null, lastEtag: null }; + + const $ = (id) => document.getElementById(id); + const panel = $("syncPanel"); + const syncBtn = $("syncBtn"); + const badge = $("syncBadge"); + + // 上传成功后在同步图标右下角显示绿色✅;同步中/失败/登出时隐藏 + function showBadge(on) { + if (badge) badge.hidden = !on; + } + + // 旧版配置若残留「自建服务器」模式,统一迁移为 WebDAV + if (cfg.mode === "server") { delete cfg.mode; saveCfg(); } + + // ---------- 配置持久化 ---------- + function loadCfg() { + try { return JSON.parse(localStorage.getItem(SYNC_KEY)) || {}; } + catch { return {}; } + } + function saveCfg() { localStorage.setItem(SYNC_KEY, JSON.stringify(cfg)); } + + // ---------- 加解密(Web Crypto,端到端) ---------- + function b64(buf) { + const bytes = new Uint8Array(buf); + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); + } + function b64ToBytes(b) { + const s = atob(b); + const a = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i); + return a; + } + async function deriveKey(password, username) { + const enc = new TextEncoder(); + const salt = enc.encode("jiandan-sync-v1::" + username); + const base = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + } + async function enc(key, text) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text || "")); + return b64(iv) + ":" + b64(ct); + } + async function dec(key, blob) { + if (!blob) return ""; + const [ivb, ctb] = String(blob).split(":"); + if (!ivb || !ctb) return ""; + const iv = b64ToBytes(ivb); + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, b64ToBytes(ctb)); + return new TextDecoder().decode(pt); + } + + // ---------- 工具 ---------- + async function encryptDoc(key, d) { + return { id: d.id, title: await enc(key, d.title), content: await enc(key, d.content), updated: d.updated }; + } + async function decryptDoc(key, e) { + try { + return { id: e.id, title: await dec(key, e.title), content: await dec(key, e.content), updated: e.updated }; + } catch (e) { return null; } + } + function mergeIntoLocal(remoteDocs) { + const apiObj = CC(); + const map = {}; + for (const d of apiObj.getDocs()) map[d.id] = d; + for (const r of remoteDocs) { + const ex = map[r.id]; + if (!ex || (r.updated || 0) > (ex.updated || 0)) map[r.id] = r; + } + apiObj.setDocs(Object.values(map)); + } + + // ---------- 路径工具(备份存档用) ---------- + const pad2 = (n) => String(n).padStart(2, "0"); + function dirOf(p) { const i = String(p || "").lastIndexOf("/"); return i >= 0 ? p.slice(0, i) : ""; } + function joinPath(dir, name) { return dir ? dir.replace(/\/$/, "") + "/" + name : name; } + function backupName() { + const d = new Date(); + return `jiandan-backup-${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}.json`; + } + function fmtBackupLabel(name) { + const m = name.match(/jiandan-backup-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.json/i); + return m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : name; + } + + // ---------- WebDAV 代理 API(同源,绕过 CORS) ---------- + async function wdApi(ep, o) { + try { + const res = await fetch("/api/webdav/" + ep, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(o), + }); + return await res.json(); + } catch (e) { return { status: 0, error: e.message }; } + } + + // ---------- 同步核心:WebDAV ---------- + let wdSyncing = false; + let wdDirty = false; + let pollTimer = null; + + async function wdSyncNow() { + if (!state.loggedIn) return; + if (wdSyncing) return; + wdSyncing = true; + showBadge(false); + setStatus("同步中…"); + try { + const wd = state.wd; + const key = state.key; + // 1. 检查远程是否有变化 + const pr = await wdApi("propfind", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass }); + if (pr.status === 401 || pr.status === 403) { setStatus("WebDAV 认证失败,请检查账号密码"); stopPoll(); return; } + if (pr.status !== 200 && pr.status !== 207 && pr.status !== 404) { + throw new Error("WebDAV " + (pr.error || pr.status)); + } + let remoteDocs = []; + if (pr.status === 200 && pr.etag && pr.etag === state.lastEtag && !wdDirty) { + // 远程无变化且本地无改动 + setStatus("已是最新 · " + CC().fmtDate(cfg.lastSync || Date.now())); + return; + } + if (pr.status === 200) { + const gr = await wdApi("get", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass }); + if (gr.status === 200 && gr.content) { + try { + const parsed = JSON.parse(gr.content); + for (const e of (parsed.docs || [])) { + const d = await decryptDoc(key, e); + if (d) remoteDocs.push(d); + } + } catch (e) { /* 忽略坏数据 */ } + } + } + // 2. 合并远程到本地 + mergeIntoLocal(remoteDocs); + // 3. 把本地最新整文件推回 WebDAV + const apiObj = CC(); + const toPush = []; + for (const d of apiObj.getDocs()) toPush.push(await encryptDoc(key, d)); + const payload = JSON.stringify({ docs: toPush, v: 1 }); + const ur = await wdApi("put", { base: wd.base, path: wd.path, user: wd.user, pass: wd.pass, content: payload }); + if (ur.status !== 200 && ur.status !== 201 && ur.status !== 204 && ur.status !== 207) { + throw new Error("上传失败 " + (ur.error || ur.status)); + } + wdDirty = false; + state.lastEtag = ur.etag || pr.etag || null; + apiObj.persist(); + apiObj.renderList(); + apiObj.renderEditor(); + cfg.lastSync = Date.now(); + saveCfg(); + showBadge(true); + setStatus("已同步 · " + apiObj.fmtDate(cfg.lastSync)); + } catch (e) { + showBadge(false); + setStatus("同步失败:" + e.message); + } finally { + wdSyncing = false; + schedulePoll(); + } + } + + function schedulePoll() { + if (pollTimer) clearTimeout(pollTimer); + if (!state.loggedIn || cfg.auto === false) return; + pollTimer = setTimeout(() => wdSyncNow(), 8000); + } + function stopPoll() { if (pollTimer) clearTimeout(pollTimer); } + + // ---------- 手动备份存档(独立快照,不覆盖主同步文件) ---------- + async function manualBackup() { + if (!state.loggedIn) { setStatus("请先登录 WebDAV"); return; } + const wd = state.wd, key = state.key; + setStatus("生成存档中…"); + try { + const apiObj = CC(); + const toPush = []; + for (const d of apiObj.getDocs()) toPush.push(await encryptDoc(key, d)); + const payload = JSON.stringify({ docs: toPush, v: 1, backupAt: Date.now() }); + const name = backupName(); + const p = joinPath(dirOf(wd.path), name); + const r = await wdApi("put", { base: wd.base, path: p, user: wd.user, pass: wd.pass, content: payload }); + if (r.status !== 200 && r.status !== 201 && r.status !== 204 && r.status !== 207) { + throw new Error("存档失败 " + (r.error || r.status)); + } + setStatus("已生成存档 · " + fmtBackupLabel(name)); + loadBackupList(); + } catch (e) { setStatus("存档失败:" + e.message); } + } + + async function loadBackupList() { + if (!state.loggedIn) return; + const wd = state.wd; + const sel = $("backupSelect"); + if (!sel) return; + try { + const r = await wdApi("list", { base: wd.base, path: dirOf(wd.path), user: wd.user, pass: wd.pass }); + const names = (r.hrefs || []) + .map((h) => h.split("/").filter(Boolean).pop() || "") + .filter((n) => /^jiandan-backup-.*\.json$/i.test(n)); + // 去重 + 按时间倒序(文件名含时间戳,字典序即时间序) + const uniq = Array.from(new Set(names)).sort().reverse(); + sel.innerHTML = ""; + if (!uniq.length) { + const o = document.createElement("option"); + o.value = ""; o.textContent = "(暂无存档)"; + sel.appendChild(o); + return; + } + for (const n of uniq) { + const o = document.createElement("option"); + o.value = n; o.textContent = fmtBackupLabel(n); + sel.appendChild(o); + } + } catch (e) { /* 忽略列表加载失败 */ } + } + + async function restoreBackup() { + if (!state.loggedIn) { setStatus("请先登录 WebDAV"); return; } + const sel = $("backupSelect"); + const name = sel ? sel.value : ""; + if (!name) { setStatus("请先选择一个存档"); return; } + if (!window.confirm(`确定用存档「${fmtBackupLabel(name)}」恢复吗?\n将与当前内容合并(同一篇以更新时间较新者为准),不会删除本地文章。`)) return; + const wd = state.wd, key = state.key; + setStatus("恢复中…"); + try { + const p = joinPath(dirOf(wd.path), name); + const gr = await wdApi("get", { base: wd.base, path: p, user: wd.user, pass: wd.pass }); + if (gr.status !== 200 || !gr.content) throw new Error("读取存档失败 " + (gr.error || gr.status)); + const parsed = JSON.parse(gr.content); + const remoteDocs = []; + for (const e of (parsed.docs || [])) { const d = await decryptDoc(key, e); if (d) remoteDocs.push(d); } + if (!remoteDocs.length) throw new Error("存档为空或解密失败(检查加密密码)"); + mergeIntoLocal(remoteDocs); + const apiObj = CC(); + apiObj.persist(); apiObj.renderList(); apiObj.renderEditor(); + setStatus("已从存档恢复 · " + fmtBackupLabel(name)); + wdDirty = true; + wdSyncNow(); // 把恢复合并后的结果推回主同步文件 + } catch (e) { setStatus("恢复失败:" + e.message); } + } + + // ---------- 统一入口 ---------- + function syncNow() { return wdSyncNow(); } + let wdTimer = null; + function scheduleSync() { + if (!state.loggedIn) return; + wdDirty = true; + if (cfg.auto === false) return; + if (wdTimer) clearTimeout(wdTimer); + wdTimer = setTimeout(() => wdSyncNow(), 1200); + } + window.JianDanSync = { scheduleSync }; + + // ---------- 登录:WebDAV ---------- + async function doWdLogin() { + const base = $("wdServer").value.trim(); + const path = $("wdPath").value.trim() || "jiandan-sync.json"; + const user = $("wdUser").value.trim(); + const pass = $("wdPass").value; + const cryptoPass = $("wdCrypto").value; + if (!base || !user || !pass || !cryptoPass) { setStatus("请填写完整(WebDAV 账号/密码 + 加密密码)"); return; } + if (!crypto.subtle) { setStatus("需 HTTPS 才能同步"); return; } + const autoOn = $("syncAutoLogin") ? $("syncAutoLogin").checked : true; + setStatus("连接 WebDAV 中…"); + try { + const pr = await wdApi("propfind", { base, path, user, pass }); + if (pr.status !== 200 && pr.status !== 207 && pr.status !== 404) { + throw new Error("连接失败(" + (pr.error || pr.status) + "),检查地址/账号"); + } + const key = await deriveKey(cryptoPass, user); + state = { token: null, key, loggedIn: true, username: user, wd: { base, path, user, pass }, lastEtag: null }; + cfg.webdav = { base, path, user, pass, crypto: cryptoPass }; + cfg.auto = autoOn !== false; saveCfg(); + showReady(); + wdSyncNow(); + } catch (e) { setStatus("登录失败:" + e.message); } + } + + function doLogout() { + stopPoll(); + showBadge(false); + state = { token: null, key: null, loggedIn: false, username: "", wd: null, lastEtag: null }; + delete cfg.webdav; + saveCfg(); + showLogin(); setStatus(""); + } + + // ---------- UI ---------- + function showReady() { + $("syncLogin").hidden = true; + $("syncReady").hidden = false; + $("syncUserLabel").textContent = state.username + "(WebDAV)"; + $("syncAuto").checked = cfg.auto !== false; + $("syncStatus").textContent = cfg.lastSync ? ("上次同步 " + CC().fmtDate(cfg.lastSync)) : ""; + loadBackupList(); + } + function showLogin() { + $("syncLogin").hidden = false; + $("syncReady").hidden = true; + const autoEl = $("syncAutoLogin"); + if (autoEl) autoEl.checked = cfg.auto !== false; + if (cfg.webdav) { + $("wdServer").value = cfg.webdav.base || ""; + $("wdPath").value = cfg.webdav.path || "jiandan-sync.json"; + $("wdUser").value = cfg.webdav.user || ""; + $("wdPass").value = cfg.webdav.pass || ""; + $("wdCrypto").value = cfg.webdav.crypto || ""; + } + } + function setStatus(t) { const el = $("syncStatus"); if (el) el.textContent = t; } + + function openPanel() { + if (!state.loggedIn) showLogin(); else showReady(); + panel.hidden = false; + } + function closePanel() { panel.hidden = true; } + + // ---------- 事件 ---------- + syncBtn.addEventListener("click", (e) => { e.stopPropagation(); panel.hidden = !panel.hidden; if (!panel.hidden) openPanel(); }); + $("syncClose").addEventListener("click", closePanel); + $("syncLoginBtn").addEventListener("click", doWdLogin); + $("syncNow").addEventListener("click", syncNow); + $("syncLogout").addEventListener("click", doLogout); + $("backupNow").addEventListener("click", manualBackup); + $("backupRefresh").addEventListener("click", loadBackupList); + $("backupRestore").addEventListener("click", restoreBackup); + $("syncAuto").addEventListener("change", (e) => { cfg.auto = e.target.checked; saveCfg(); if (e.target.checked) schedulePoll(); }); + document.addEventListener("click", (e) => { + if (!panel.hidden && !panel.contains(e.target) && e.target.id !== "syncBtn") closePanel(); + }); + ["wdServer", "wdPath", "wdUser", "wdPass", "wdCrypto"].forEach((id) => + $(id).addEventListener("keydown", (e) => { if (e.key === "Enter") doWdLogin(); }) + ); + + // ---------- WebDAV 自动恢复(刷新后免登录继续同步) ---------- + if (cfg.webdav && cfg.webdav.base && cfg.webdav.crypto) { + (async () => { + try { + const w = cfg.webdav; + const key = await deriveKey(w.crypto, w.user); + const pr = await wdApi("propfind", { base: w.base, path: w.path, user: w.user, pass: w.pass }); + state = { token: null, key, loggedIn: true, username: w.user, wd: { base: w.base, path: w.path, user: w.user, pass: w.pass }, lastEtag: null }; + showReady(); + wdSyncNow(); + } catch (e) { /* 忽略,等待用户手动登录 */ } + })(); + } +})(); diff --git a/test_start.js b/test_start.js new file mode 100644 index 0000000..95cb9cb --- /dev/null +++ b/test_start.js @@ -0,0 +1,24 @@ +const http = require("http"); +function req(path, method, data) { + return new Promise((resolve, reject) => { + const body = data ? JSON.stringify(data) : null; + const headers = { "Content-Type": "application/json" }; + if (body) headers["Content-Length"] = Buffer.byteLength(body); + const r = http.request({ host: "localhost", port: 8080, path, method, headers }, (res) => { + let d = ""; res.on("data", (c) => (d += c)); res.on("end", () => resolve({ status: res.statusCode, body: d.slice(0, 200) })); + }); + r.on("error", reject); + if (body) r.write(body); + r.end(); + }); +} +(async () => { + // 静态页面 + const page = await req("/", "GET"); + console.log("GET / ->", page.status, page.body.includes("简单写作") ? "(含页面)" : ""); + const js = await req("/app.js", "GET"); + console.log("GET /app.js ->", js.status); + // WebDAV 代理(用一个无效地址,验证代理在运行并能正确返回错误而非崩溃) + const wd = await req("/api/webdav/propfind", "POST", { base: "https://invalid.invalid.example/", path: "x.json", user: "u", pass: "p" }); + console.log("webdav proxy ->", wd.status, "(代理已响应,预期非 200)"); +})(); diff --git a/使用说明.md b/使用说明.md new file mode 100644 index 0000000..eb8e6c3 --- /dev/null +++ b/使用说明.md @@ -0,0 +1,84 @@ +# 简单写作 · 使用说明 + +## 启动项目 + +在项目目录下执行: + +```bash +node src/start.js +``` + +看到 `[简单写作] 已启动 → http://localhost:8080` 后,浏览器打开该地址即可使用。 + +也可以用 `npm start` 一键启动。 + +指定端口:`PORT=9000 node src/start.js`(PowerShell 用 `$env:PORT=9000; node src/start.js`) + +--- + +## 安装为桌面应用(PWA) + +打开 http://localhost:8080 后,浏览器地址栏右侧会出现安装图标 ⊕,点击即可安装到桌面,以后像本地软件一样打开,支持离线写作。 + +--- + +## 基本功能 + +| 功能 | 操作 | +|---|---| +| 新建文章 | 左侧列表点 ➕ | +| 切换文章 | 左侧列表点击对应项 | +| 删除文章 | 左侧列表点 🗑 | +| 重命名文章 | 点笔图标(移动端)或直接在顶栏编辑(桌面端) | +| 导出 | 工具栏点「导出」→ 选 TXT / Markdown / 复制全文 | +| 字数统计 | 自动显示在顶栏 | +| 专注模式 | 按 F11,隐藏一切 UI | + +--- + +## 排版设置 + +点齿轮图标打开设置面板: + +- **行间距**:文字行之间的间距 +- **段间距**:段落之间的间距 +- **段首缩进**:每段开头缩进(纯 CSS,不写入正文) +- **打字机滚动**:输入行始终保持在屏幕中央 + +--- + +## 多端同步(WebDAV) + +1. 点右上角 ☁ 打开同步面板 +2. 填写 WebDAV 信息: + - **服务器地址**:如坚果云 `https://dav.jianguoyun.com/dav/` + - **存储路径**:默认 `jiandan-sync.json` + - **用户名 / 密码**:网盘账号 + - **加密密码**:端到端加密密码(请牢记) +3. 点「登录并同步」 + +**注意:** 必须通过 `node src/start.js` 启动才能使用同步,直接用 `file://` 打开不行。 + +--- + +## 构建 Android APK + +```bash +scripts\build_apk.bat +``` + +首次构建前需配置环境: + +```bash +powershell -File scripts\install_android_build.ps1 +``` + +--- + +## 目录结构 + +``` +src/ 源代码(网页、样式、JS、图标、服务器) +android/ Android 构建工程 +scripts/ 构建/安装脚本 +```