mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-02-22 20:18:48 +00:00
Compare commits
109 Commits
0.24.0.dev
...
0.24.23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00dfae24d7 | ||
|
|
d8a41fe45d | ||
|
|
30467d1c25 | ||
|
|
f8351f1d45 | ||
|
|
5924af98ab | ||
|
|
2769b61da4 | ||
|
|
bb4409221d | ||
|
|
f398c14200 | ||
|
|
27d58508dc | ||
|
|
d4dea5b226 | ||
|
|
c79dc30cba | ||
|
|
b3119ee8a9 | ||
|
|
2a1d71da5c | ||
|
|
24f31ed19e | ||
|
|
a982629ae6 | ||
|
|
85140aecab | ||
|
|
3f2e23ee88 | ||
|
|
6049c19e8a | ||
|
|
65648683a3 | ||
|
|
5d70f2c1e9 | ||
|
|
cbcfdc453e | ||
|
|
a4eb21593c | ||
|
|
05eb2c8262 | ||
|
|
fecefa3631 | ||
|
|
f8c4d5ccb0 | ||
|
|
e63e79bc8e | ||
|
|
ed76125f3d | ||
|
|
70f4e23474 | ||
|
|
f6d5b78cc8 | ||
|
|
405624b51b | ||
|
|
90c0ff22b9 | ||
|
|
67568ea886 | ||
|
|
cc29b4058d | ||
|
|
4e8243b3d5 | ||
|
|
4eb1787784 | ||
|
|
1cd1465f2c | ||
|
|
45ceca8bb6 | ||
|
|
7b385aab9e | ||
|
|
98411e5f48 | ||
|
|
b6687e2fb0 | ||
|
|
658cbb7ded | ||
|
|
08a48154fa | ||
|
|
62501a5940 | ||
|
|
ccb3dd52de | ||
|
|
3e5f4c8946 | ||
|
|
54e64c59a9 | ||
|
|
588840ff8b | ||
|
|
e6b8dfb279 | ||
|
|
73782c5389 | ||
|
|
f2b667d75e | ||
|
|
9b1588a65b | ||
|
|
0629bc04bb | ||
|
|
b0e97e6c96 | ||
|
|
7f853b0222 | ||
|
|
3d4ad4a3b4 | ||
|
|
4b1fff852a | ||
|
|
08d7d24baf | ||
|
|
9db3c3df0a | ||
|
|
672940ad6f | ||
|
|
2338601fae | ||
|
|
3e657b38a9 | ||
|
|
21861d8c51 | ||
|
|
3bb4aba395 | ||
|
|
751de5a13e | ||
|
|
29229f809b | ||
|
|
2d0dc2a389 | ||
|
|
6cbe319b80 | ||
|
|
e9fe58f818 | ||
|
|
61524e1c44 | ||
|
|
c25eaa09c9 | ||
|
|
71987e6814 | ||
|
|
b15d0710e5 | ||
|
|
9d304b3233 | ||
|
|
d062b13040 | ||
|
|
7eceab59af | ||
|
|
ed5cb3e043 | ||
|
|
574fdf9202 | ||
|
|
9ec7b809a9 | ||
|
|
4d302aff9d | ||
|
|
b70009f4a9 | ||
|
|
c24ee32f37 | ||
|
|
012d0aa4df | ||
|
|
5c97e5b672 | ||
|
|
6e1eb36f3b | ||
|
|
8809aee327 | ||
|
|
fc04c557fc | ||
|
|
115a0d2d8a | ||
|
|
2c97289ec8 | ||
|
|
6d472d17fd | ||
|
|
3a3aabfd11 | ||
|
|
8b45dd1d24 | ||
|
|
a2b36ccf31 | ||
|
|
25e30fa09d | ||
|
|
8f5bc387b4 | ||
|
|
5afe24c460 | ||
|
|
658a09f1cc | ||
|
|
a9020a3aea | ||
|
|
293c731437 | ||
|
|
5b4ae37030 | ||
|
|
9e8d126259 | ||
|
|
6d244a6e34 | ||
|
|
1f0ad4eb1e | ||
|
|
e0e0ab0426 | ||
|
|
5023d6da0b | ||
|
|
49160c7d57 | ||
|
|
4434224c29 | ||
|
|
cf3b9e5522 | ||
|
|
f778107727 | ||
|
|
12d825ea49 |
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
build
|
||||
.eslintrc.js.bak
|
||||
src/lib/src/patches/pouchdb-utils
|
||||
esbuild.config.mjs
|
||||
rollup.config.js
|
||||
src/lib/test
|
||||
src/lib/src/cli
|
||||
main.js
|
||||
31
.eslintrc
31
.eslintrc
@@ -1,13 +1,34 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"eslint-plugin-svelte",
|
||||
"eslint-plugin-import"
|
||||
],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"project": ["tsconfig.json"]
|
||||
"project": [
|
||||
"tsconfig.json"
|
||||
]
|
||||
},
|
||||
"ignorePatterns": [],
|
||||
"ignorePatterns": [
|
||||
"**/node_modules/*",
|
||||
"**/jest.config.js",
|
||||
"src/lib/coverage",
|
||||
"src/lib/browsertest",
|
||||
"**/test.ts",
|
||||
"**/tests.ts",
|
||||
"**/**test.ts",
|
||||
"**/**.test.ts",
|
||||
"esbuild.*.mjs",
|
||||
"terser.*.mjs"
|
||||
],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
@@ -34,4 +55,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '18.x' # You might need to adjust this value to your own version
|
||||
node-version: '22.x' # You might need to adjust this value to your own version
|
||||
# Get the version number and put it in a variable
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ package-lock.json
|
||||
main.js
|
||||
main_org.js
|
||||
*.js.map
|
||||
meta.json
|
||||
meta-*.json
|
||||
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
pouchdb-browser.js
|
||||
main_org.js
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
14
README.md
14
README.md
@@ -18,6 +18,11 @@ Note: This plugin cannot synchronise with the official "Obsidian Sync".
|
||||
- Supporting End-to-end encryption.
|
||||
- Synchronisation of settings, snippets, themes, and plug-ins, via [Customization sync(Beta)](#customization-sync) or [Hidden File Sync](#hiddenfilesync)
|
||||
- WebClip from [obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
|
||||
- WebRTC peer-to-peer synchronisation without the need for any `host` is now possible. (Experimental)
|
||||
- This feature is still in the experimental stage. Please be careful when using it.
|
||||
- Instead of using public servers, you can use [webpeer](https://github.com/vrtmrz/livesync-commonlib/tree/main/apps/webpeer) the pseudo client for receiving and sending between devices.
|
||||
- A pre-built instance is served at [fancy-syncing.vrtmrz.net/webpeer](https://fancy-syncing.vrtmrz.net/webpeer/) (in the vrtmrz blog site). This is of course also peer-to-peer. Feel free to use it.
|
||||
- There is an [English explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync-en.html), and [Japanese explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync).
|
||||
|
||||
This plug-in might be useful for researchers, engineers, and developers with a need to keep their notes fully self-hosted for security reasons. Or just anyone who would like the peace of mind of knowing that their notes are fully private.
|
||||
|
||||
@@ -80,6 +85,15 @@ To prevent file and database corruption, please wait to stop Obsidian until all
|
||||
## Tips and Troubleshooting
|
||||
If you are having problems getting the plugin working see: [Tips and Troubleshooting](docs/troubleshooting.md)
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
The project has been in continual progress and harmony because of
|
||||
- Many [Contributors](https://github.com/vrtmrz/obsidian-livesync/graphs/contributors)
|
||||
- Many [GitHub Sponsors](https://github.com/sponsors/vrtmrz#sponsors)
|
||||
- JetBrains Community Programs / Support for Open-Source Projects <img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.png" alt="JetBrains logo." height="24">
|
||||
|
||||
May those who have contributed be honoured and remembered for their kindness and generosity.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the MIT License.
|
||||
|
||||
93
README_es.md
Normal file
93
README_es.md
Normal file
@@ -0,0 +1,93 @@
|
||||
<!-- For translation: 20240227r0 -->
|
||||
# Self-hosted LiveSync
|
||||
[Documentación en inglés](./README_ja.md) - [Documentación en japonés](./README_ja.md) - [Documentación en chino](./README_cn.md).
|
||||
|
||||
Self-hosted LiveSync es un plugin de sincronización implementado por la comunidad, disponible en todas las plataformas compatibles con Obsidian y utiliza CouchDB o Almacenamiento de Objetos (por ejemplo, MinIO, S3, R2, etc.) como servidor.
|
||||
|
||||

|
||||
|
||||
Nota: Este plugin no puede sincronizarse con el "Obsidian Sync" oficial.
|
||||
|
||||
## Características
|
||||
|
||||
- Sincroniza bóvedas de manera eficiente con menos tráfico.
|
||||
- Buen manejo de modificaciones en conflicto.
|
||||
- Fusión automática para conflictos simples.
|
||||
- Uso de soluciones de código abierto para el servidor.
|
||||
- Pueden usarse soluciones compatibles.
|
||||
- Soporte de cifrado de extremo a extremo.
|
||||
- Sincronización de configuraciones, fragmentos, temas y complementos a través de [Sincronización de personalización \(Beta\)](#customization-sync) o [Sincronización de archivos ocultos](#hiddenfilesync)
|
||||
- WebClip de [obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
|
||||
|
||||
Este plugin puede ser útil para investigadores, ingenieros y desarrolladores que necesitan mantener sus notas totalmente autoalojadas por razones de seguridad, o para aquellos que deseen tener la tranquilidad de saber que sus notas son totalmente privadas.
|
||||
|
||||
>[!IMPORTANTE]
|
||||
> - Antes de instalar o actualizar este plugin, realice un respaldo de su bóveda.
|
||||
> - No active este plugin junto con otra solución de sincronización al mismo tiempo (incluyendo iCloud y Obsidian Sync).
|
||||
> - Este es un plugin de sincronización, no una solución de respaldo. No confíe en él para realizar respaldos.
|
||||
|
||||
## Cómo usar
|
||||
|
||||
### Configuración en 3 minutos - CouchDB en fly.io
|
||||
|
||||
**Recomendado para principiantes**
|
||||
|
||||
[](https://www.youtube.com/watch?v=7sa_I1832Xc)
|
||||
|
||||
1. [Configurar CouchDB en fly.io](docs/setup_flyio_es.md)
|
||||
2. Configurar el plugin en [Configuración rápida](docs/quick_setup_es.md)
|
||||
|
||||
### Configuración manual
|
||||
|
||||
1. Configurar el servidor
|
||||
1. [Configurar CouchDB en fly.io](docs/setup_flyio_es.md)
|
||||
2. [Configurar su CouchDB](docs/setup_own_server_es.md)
|
||||
2. Configura el plugin en [Configuración rápida](docs/quick_setup_es.md)
|
||||
|
||||
> [!CONSEJO]
|
||||
> Actualmente, fly.io ya no es gratuito. Afortunadamente, aunque hay algunos problemas, aún podemos usar IBM Cloudant. Aquí está como [Configurar IBM Cloudant](docs/setup_cloudant.md). ¡Se actualizará pronto!
|
||||
|
||||
|
||||
## Información en la barra de estado
|
||||
|
||||
El estado de sincronización se muestra en la barra de estado con los siguientes iconos.
|
||||
|
||||
- Indicador de actividad
|
||||
- 📲 Solicitud de red
|
||||
- Estado
|
||||
- ⏹️ Detenido
|
||||
- 💤 LiveSync activado. Esperando cambios
|
||||
- ⚡️ Sincronización en progreso
|
||||
- ⚠ Ocurrió un error
|
||||
- Indicador estadístico
|
||||
- ↑ Chunks y metadatos subidos
|
||||
- ↓ Chunks y metadatos descargados
|
||||
- Indicador de progreso
|
||||
- 📥 Elementos transferidos sin procesar
|
||||
- 📄 Operación de base de datos en curso
|
||||
- 💾 Procesos de escritura en almacenamiento en curso
|
||||
- ⏳ Procesos de lectura en almacenamiento en curso
|
||||
- 🛫 Procesos de lectura en almacenamiento pendientes
|
||||
- 📬 Procesos de lectura en almacenamiento por lotes
|
||||
- ⚙️ Procesos de almacenamiento de archivos ocultos en curso o pendientes
|
||||
- 🧩 Chunks en espera
|
||||
- 🔌 Elementos de personalización en curso (Configuración, fragmentos y plugins)
|
||||
|
||||
Para prevenir la corrupción de archivos y bases de datos, antes de detener Obsidian espere hasta que todos los indicadores de progreso hayan desaparecido (el plugin también intentará reanudar, sin embargo). Especialmente en caso de que haya eliminado o renombrado archivos.
|
||||
|
||||
|
||||
## Consejos y Solución de Problemas
|
||||
Si tienes problemas para hacer funcionar el plugin, consulta: [Consejos y solución de problemas](docs/troubleshooting_es.md).
|
||||
|
||||
## Agradecimientos
|
||||
|
||||
El proyecto ha progresado y mantenido en armonía gracias a:
|
||||
- Muchos [Colaboradores](https://github.com/vrtmrz/obsidian-livesync/graphs/contributors)
|
||||
- Muchos [Patrocinadores de GitHub](https://github.com/sponsors/vrtmrz#sponsors)
|
||||
- Programas comunitarios de JetBrains / Soporte para Proyectos de Código Abierto <img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.png" alt="JetBrains logo." height="24">
|
||||
|
||||
Que aquellos que han contribuido sean honrados y recordados por su amabilidad y generosidad.
|
||||
|
||||
## Licencia
|
||||
|
||||
Licenciado bajo la Licencia MIT.
|
||||
@@ -25,10 +25,10 @@ npm run buildDev
|
||||
## Make messages to be translated
|
||||
|
||||
1. Find the message that you want to be translated.
|
||||
2. Change the literal to a tagged template literal using `$f`, like below.
|
||||
2. Change the literal to use `$tf`, like below.
|
||||
```diff
|
||||
- Logger("Could not determine passphrase to save data.json! You probably make the configuration sure again!", LOG_LEVEL_URGENT);
|
||||
+ Logger($f`Could not determine passphrase to save data.json! You probably make the configuration sure again!`, LOG_LEVEL_URGENT);
|
||||
+ Logger($tf('someKeyForPassphraseError'), LOG_LEVEL_URGENT);
|
||||
```
|
||||
3. Make the PR to `https://github.com/vrtmrz/obsidian-livesync`.
|
||||
4. Follow the steps of "Add translations for already defined terms" to add the translations.
|
||||
137
docs/settings.md
137
docs/settings.md
@@ -6,7 +6,7 @@ There are many settings in Self-hosted LiveSync. This document describes each se
|
||||
|
||||
| Icon | Description |
|
||||
| :--: | ------------------------------------------------------------------ |
|
||||
| 💬 | [0. Update Information](#0-update-information) |
|
||||
| 💬 | [0. Change Log](#0-change-log) |
|
||||
| 🧙♂️ | [1. Setup](#1-setup) |
|
||||
| ⚙️ | [2. General Settings](#2-general-settings) |
|
||||
| 🛰️ | [3. Remote Configuration](#3-remote-configuration) |
|
||||
@@ -19,7 +19,7 @@ There are many settings in Self-hosted LiveSync. This document describes each se
|
||||
| 🩹 | [10. Patches (Edge Case)](#10-patches-edge-case) |
|
||||
| 🎛️ | [11. Maintenance](#11-maintenance) |
|
||||
|
||||
## 0. Update Information
|
||||
## 0. Change Log
|
||||
|
||||
This pane shows version up information. You can check what has been changed in recent versions.
|
||||
|
||||
@@ -31,21 +31,21 @@ This pane is used for setting up Self-hosted LiveSync. There are several options
|
||||
|
||||
Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted LiveSync with a few clicks.
|
||||
|
||||
#### Use the copied setup URI
|
||||
#### Connect with Setup URI
|
||||
|
||||
Setup the Self-hosted LiveSync with the `setup URI` which is [copied from another device](#copy-current-settings-as-a-new-setup-uri) or the setup script.
|
||||
|
||||
#### Minimal setup
|
||||
#### Manual setup
|
||||
|
||||
Step-by-step setup for Self-hosted LiveSync. You can setup Self-hosted LiveSync manually with Minimal setting items.
|
||||
|
||||
#### Enable LiveSync on this device as the setup was completed manually
|
||||
#### Enable LiveSync
|
||||
|
||||
This button only appears when the setup was not completed. If you have completed the setup manually, you can enable LiveSync on this device by this button.
|
||||
|
||||
### 2. To setup the other devices
|
||||
### 2. To setup other devices
|
||||
|
||||
#### Copy current settings as a new setup URI
|
||||
#### Copy the current settings to a Setup URI
|
||||
|
||||
You can copy the current settings as a new setup URI. And this URI can be used to setup the other devices as [Use the copied setup URI](#use-the-copied-setup-uri).
|
||||
|
||||
@@ -71,7 +71,7 @@ Following panes will be shown when you enable this setting.
|
||||
| 🔌 | [6. Customization sync (Advanced)](#6-customization-sync-advanced) |
|
||||
| 🔧 | [8. Advanced (Advanced)](#8-advanced-advanced) |
|
||||
|
||||
#### Enable power user features
|
||||
#### Enable poweruser features
|
||||
|
||||
Setting key: usePowerUserMode
|
||||
|
||||
@@ -152,7 +152,7 @@ Setting key: notifyThresholdOfRemoteStorageSize
|
||||
|
||||
MB (0 to disable). We can get a notification when the estimated remote storage size exceeds this value.
|
||||
|
||||
### 3. Confidentiality
|
||||
### 3. Privacy & Encryption
|
||||
|
||||
#### End-to-End Encryption
|
||||
|
||||
@@ -178,13 +178,19 @@ Setting key: useDynamicIterationCount
|
||||
|
||||
This is an experimental feature and not recommended. If you enable this, the iteration count of the encryption will be dynamically determined. This is useful when you want to improve the performance.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
**now writing from here onwards, sorry**
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
### 4. Minio,S3,R2
|
||||
### 4. Fetch settings
|
||||
|
||||
#### Fetch config from remote server
|
||||
|
||||
Fetch necessary settings from already configured remote server.
|
||||
|
||||
### 5. Minio,S3,R2
|
||||
|
||||
#### Endpoint URL
|
||||
|
||||
@@ -209,15 +215,15 @@ Setting key: bucket
|
||||
#### Use Custom HTTP Handler
|
||||
|
||||
Setting key: useCustomRequestHandler
|
||||
If your Object Storage could not configured accepting CORS, enable this.
|
||||
Enable this if your Object Storage doesn't support CORS
|
||||
|
||||
#### Test Connection
|
||||
|
||||
#### Apply Settings
|
||||
|
||||
### 5. CouchDB
|
||||
### 6. CouchDB
|
||||
|
||||
#### URI
|
||||
#### Server URI
|
||||
|
||||
Setting key: couchDB_URI
|
||||
|
||||
@@ -231,17 +237,17 @@ username
|
||||
Setting key: couchDB_PASSWORD
|
||||
password
|
||||
|
||||
#### Database name
|
||||
#### Database Name
|
||||
|
||||
Setting key: couchDB_DBNAME
|
||||
|
||||
#### Test Database Connection
|
||||
|
||||
Open database connection. If the remote database is not found and you have the privilege to create a database, the database will be created.
|
||||
Open database connection. If the remote database is not found and you have permission to create a database, the database will be created.
|
||||
|
||||
#### Check and fix database configuration
|
||||
#### Validate Database Configuration
|
||||
|
||||
Check the database configuration, and fix if there are any problems.
|
||||
Checks and fixes any potential issues with the database config.
|
||||
|
||||
#### Apply Settings
|
||||
|
||||
@@ -254,7 +260,7 @@ Check the database configuration, and fix if there are any problems.
|
||||
Setting key: preset
|
||||
Apply preset configuration
|
||||
|
||||
### 2. Synchronization Methods
|
||||
### 2. Synchronization Method
|
||||
|
||||
#### Sync Mode
|
||||
|
||||
@@ -268,22 +274,22 @@ Interval (sec)
|
||||
#### Sync on Save
|
||||
|
||||
Setting key: syncOnSave
|
||||
When you save a file, sync automatically
|
||||
Starts synchronisation when a file is saved.
|
||||
|
||||
#### Sync on Editor Save
|
||||
|
||||
Setting key: syncOnEditorSave
|
||||
When you save a file in the editor, sync automatically
|
||||
When you save a file in the editor, start a sync automatically
|
||||
|
||||
#### Sync on File Open
|
||||
|
||||
Setting key: syncOnFileOpen
|
||||
When you open a file, sync automatically
|
||||
Forces the file to be synced when opened.
|
||||
|
||||
#### Sync on Start
|
||||
#### Sync on Startup
|
||||
|
||||
Setting key: syncOnStart
|
||||
Start synchronization after launching Obsidian.
|
||||
Automatically Sync all files when opening Obsidian.
|
||||
|
||||
#### Sync after merging file
|
||||
|
||||
@@ -312,34 +318,36 @@ Saving will be performed forcefully after this number of seconds.
|
||||
#### Use the trash bin
|
||||
|
||||
Setting key: trashInsteadDelete
|
||||
Do not delete files that are deleted in remote, just move to trash.
|
||||
Move remotely deleted files to the trash, instead of deleting.
|
||||
|
||||
#### Keep empty folder
|
||||
|
||||
Setting key: doNotDeleteFolder
|
||||
Normally, a folder is deleted when it becomes empty after a synchronization. Enabling this will prevent it from getting deleted
|
||||
Should we keep folders that don't have any files inside?
|
||||
|
||||
### 5. Conflict resolution (Advanced)
|
||||
|
||||
#### Always overwrite with a newer file (beta)
|
||||
#### (BETA) Always overwrite with a newer file
|
||||
|
||||
Setting key: resolveConflictsByNewerFile
|
||||
(Def off) Resolve conflicts by newer files automatically.
|
||||
Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.
|
||||
|
||||
#### Postpone resolution of inactive files
|
||||
#### Delay conflict resolution of inactive files
|
||||
|
||||
Setting key: checkConflictOnlyOnOpen
|
||||
Should we only check for conflicts when a file is opened?
|
||||
|
||||
#### Postpone manual resolution of inactive files
|
||||
#### Delay merge conflict prompt for inactive files.
|
||||
|
||||
Setting key: showMergeDialogOnlyOnActive
|
||||
Should we prompt you about conflicting files when a file is opened?
|
||||
|
||||
### 6. Sync settings via markdown (Advanced)
|
||||
|
||||
#### Filename
|
||||
|
||||
Setting key: settingSyncFile
|
||||
If you set this, all settings are saved in a markdown file. You will be notified when new settings arrive. You can set different files by the platform.
|
||||
Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.
|
||||
|
||||
#### Write credentials in the file
|
||||
|
||||
@@ -350,7 +358,7 @@ Setting key: writeCredentialsForSettingSync
|
||||
|
||||
Setting key: notifyAllSettingSyncFile
|
||||
|
||||
### 7. Hidden files (Advanced)
|
||||
### 7. Hidden Files (Advanced)
|
||||
|
||||
#### Hidden file synchronization
|
||||
|
||||
@@ -390,7 +398,7 @@ If this is set, changes to local files which are matched by the ignore files wil
|
||||
#### Ignore files
|
||||
|
||||
Setting key: ignoreFiles
|
||||
We can use multiple ignore files, e.g.) `.gitignore, .dockerignore`
|
||||
Comma separated `.gitignore, .dockerignore`
|
||||
|
||||
### 2. Hidden Files (Advanced)
|
||||
|
||||
@@ -451,7 +459,7 @@ Warning! This will have a serious impact on performance. And the logs will not b
|
||||
#### Suspend file watching
|
||||
|
||||
Setting key: suspendFileWatching
|
||||
Stop watching for file change.
|
||||
Stop watching for file changes.
|
||||
|
||||
#### Suspend database reflecting
|
||||
|
||||
@@ -464,6 +472,10 @@ Stop reflecting database changes to storage files.
|
||||
|
||||
This will recreate chunks for all files. If there were missing chunks, this may fix the errors.
|
||||
|
||||
#### Resolve All conflicted files by the newer one
|
||||
|
||||
Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.
|
||||
|
||||
#### Verify and repair all files
|
||||
|
||||
Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.
|
||||
@@ -520,16 +532,6 @@ Setting key: concurrencyOfReadChunksOnline
|
||||
|
||||
Setting key: minimumIntervalOfReadChunksOnline
|
||||
|
||||
#### Send chunks in bulk
|
||||
|
||||
Setting key: sendChunksBulk
|
||||
If this enabled, all chunks will be sent in bulk. This is useful for the environment that has a high latency.
|
||||
|
||||
#### Maximum size of chunks to send in one request
|
||||
|
||||
Setting key: sendChunksBulkMaxSize
|
||||
MB
|
||||
|
||||
## 9. Power users (Power User)
|
||||
|
||||
### 1. Remote Database Tweak
|
||||
@@ -563,7 +565,7 @@ Setting key: enableCompression
|
||||
#### Batch size
|
||||
|
||||
Setting key: batch_size
|
||||
Number of change feed items to process at a time. Defaults to 50. Minimum is 2.
|
||||
Number of changes to sync at a time. Defaults to 50. Minimum is 2.
|
||||
|
||||
#### Batch limit
|
||||
|
||||
@@ -586,6 +588,13 @@ Setting key: configPassphraseStore
|
||||
Setting key: configPassphrase
|
||||
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.
|
||||
|
||||
### 4. Developer
|
||||
|
||||
#### Enable Developers' Debug Tools.
|
||||
|
||||
Setting key: enableDebugTools
|
||||
Requires restart of Obsidian
|
||||
|
||||
## 10. Patches (Edge Case)
|
||||
|
||||
### 1. Compatibility (Metadata)
|
||||
@@ -601,15 +610,15 @@ Setting key: automaticallyDeleteMetadataOfDeletedFiles
|
||||
|
||||
### 2. Compatibility (Conflict Behaviour)
|
||||
|
||||
#### Always resolve conflicts manually
|
||||
#### Always prompt merge conflicts
|
||||
|
||||
Setting key: disableMarkdownAutoMerge
|
||||
If this switch is turned on, a merge dialog will be displayed, even if the sensible-merge is possible automatically. (Turn on to previous behavior)
|
||||
Should we prompt you for every single merge, even if we can safely merge automatcially?
|
||||
|
||||
#### Always reflect synchronized changes even if the note has a conflict
|
||||
#### Apply Latest Change if Conflicting
|
||||
|
||||
Setting key: writeDocumentsIfConflicted
|
||||
Turn on to previous behavior
|
||||
Enable this option to automatically apply the most recent change to documents even when it conflicts
|
||||
|
||||
### 3. Compatibility (Database structure)
|
||||
|
||||
@@ -655,7 +664,7 @@ Setting key: doNotSuspendOnFetching
|
||||
#### Keep empty folder
|
||||
|
||||
Setting key: doNotDeleteFolder
|
||||
Normally, a folder is deleted when it becomes empty after a synchronization. Enabling this will prevent it from getting deleted
|
||||
Should we keep folders that don't have any files inside?
|
||||
|
||||
### 7. Edge case addressing (Processing)
|
||||
|
||||
@@ -679,15 +688,15 @@ Setting key: disableCheckingConfigMismatch
|
||||
|
||||
### 1. Scram!
|
||||
|
||||
#### Lock remote
|
||||
#### Lock Server
|
||||
|
||||
Lock remote to prevent synchronization with other devices.
|
||||
Lock the remote server to prevent synchronization with other devices.
|
||||
|
||||
#### Emergency restart
|
||||
|
||||
place the flag file to prevent all operation and restart.
|
||||
Disables all synchronization and restart.
|
||||
|
||||
### 2. Data-complementary Operations
|
||||
### 2. Syncing
|
||||
|
||||
#### Resend
|
||||
|
||||
@@ -719,9 +728,9 @@ Rebuild local and remote database with local files.
|
||||
|
||||
### 5. Rebuilding Operations (Remote Only)
|
||||
|
||||
#### Perform compaction
|
||||
#### Perform cleanup
|
||||
|
||||
Compaction discards all of Eden in the non-latest revisions, reducing the storage usage. However, this operation requires the same free space on the remote as the current database.
|
||||
Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.
|
||||
|
||||
#### Overwrite remote
|
||||
|
||||
@@ -733,18 +742,18 @@ Initialise all journal history, On the next sync, every item will be received an
|
||||
|
||||
#### Purge all journal counter
|
||||
|
||||
Purge all sending and downloading cache.
|
||||
Purge all download/upload cache.
|
||||
|
||||
#### Make empty the bucket
|
||||
#### Fresh Start Wipe
|
||||
|
||||
Delete all data on the remote.
|
||||
Delete all data on the remote server.
|
||||
|
||||
### 6. Niches
|
||||
### 6. Deprecated
|
||||
|
||||
#### (Obsolete) Clean up databases
|
||||
#### Run database cleanup
|
||||
|
||||
Delete unused chunks to shrink the database. However, this feature could be not effective in some cases. Please use rebuild everything instead.
|
||||
Attempt to shrink the database by deleting unused chunks. This may not work consistently. Use the 'Rebuild everything' under Total Overhaul.
|
||||
|
||||
### 7. Reset
|
||||
|
||||
#### Discard local database to reset or uninstall Self-hosted LiveSync
|
||||
#### Delete local database to reset or uninstall Self-hosted LiveSync
|
||||
|
||||
@@ -31,7 +31,7 @@ export hostname=localhost:5984
|
||||
export username=goojdasjdas #Please change as you like.
|
||||
export password=kpkdasdosakpdsa #Please change as you like
|
||||
|
||||
# Prepare directories which saving data and configurations.
|
||||
# Prepare directories which save data and configurations.
|
||||
mkdir couchdb-data
|
||||
mkdir couchdb-etc
|
||||
```
|
||||
@@ -45,19 +45,19 @@ $ docker run --name couchdb-for-ols --rm -it -e COUCHDB_USER=${username} -e COUC
|
||||
If your container has been exited, please check the permission of couchdb-data, and couchdb-etc.
|
||||
Once CouchDB run, these directories will be owned by uid:`5984`. Please chown it for you again.
|
||||
|
||||
2. Enable it in background
|
||||
2. Enable it in the background
|
||||
```
|
||||
$ docker run --name couchdb-for-ols -d --restart always -e COUCHDB_USER=${username} -e COUCHDB_PASSWORD=${password} -v ${PWD}/couchdb-data:/opt/couchdb/data -v ${PWD}/couchdb-etc:/opt/couchdb/etc/local.d -p 5984:5984 couchdb
|
||||
```
|
||||
### B. Install CouchDB directly
|
||||
Please refer the [official document](https://docs.couchdb.org/en/stable/install/index.html). However, we do not have to configure it fully. Just administrator needs to be configured.
|
||||
Please refer to the [official document](https://docs.couchdb.org/en/stable/install/index.html). However, we do not have to configure it fully. Just the administrator needs to be configured.
|
||||
|
||||
## 2. Run couchdb-init.sh for initialise
|
||||
```
|
||||
$ curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash
|
||||
curl -s https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/couchdb-init.sh | bash
|
||||
```
|
||||
|
||||
If it results like following:
|
||||
If it results like the following:
|
||||
```
|
||||
-- Configuring CouchDB by REST APIs... -->
|
||||
{"ok":true}
|
||||
@@ -80,10 +80,15 @@ Your CouchDB has been initialised successfully. If you want this manually, pleas
|
||||
- You can skip this instruction if you using only in intranet and only with desktop devices.
|
||||
- For mobile devices, Obsidian requires a valid SSL certificate. Usually, it needs exposing the internet.
|
||||
|
||||
Whatever solutions we can use. For the simplicity, following sample uses Cloudflare Zero Trust for testing.
|
||||
Whatever solutions we can use. For simplicity, the following sample uses Cloudflare Zero Trust for testing.
|
||||
|
||||
```
|
||||
cloudflared tunnel --url http://localhost:5984
|
||||
```
|
||||
|
||||
You will then get the following output:
|
||||
|
||||
```
|
||||
$ cloudflared tunnel --url http://localhost:5984
|
||||
2024-02-14T10:35:25Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
|
||||
2024-02-14T10:35:25Z INF Requesting new quick Tunnel on trycloudflare.com...
|
||||
2024-02-14T10:35:26Z INF +--------------------------------------------------------------------------------------------+
|
||||
@@ -94,19 +99,33 @@ $ cloudflared tunnel --url http://localhost:5984
|
||||
:
|
||||
:
|
||||
```
|
||||
Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our server. Make it into background once please.
|
||||
Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our server. Make it into the background once, please.
|
||||
|
||||
|
||||
## 4. Client Setup
|
||||
> [!TIP]
|
||||
> Now manually configuration is not recommended for some reasons. However, if you want to do so, please use `Setup wizard`. The recommended extra configurations will be also set.
|
||||
> Now manual configuration is not recommended for some reasons. However, if you want to do so, please use `Setup wizard`. The recommended extra configurations will be also set.
|
||||
|
||||
### 1. Generate the setup URI on a desktop device or server
|
||||
```bash
|
||||
$ export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com #Point to your vault
|
||||
$ export database=obsidiannotes #Please change as you like
|
||||
$ export passphrase=dfsapkdjaskdjasdas #Please change as you like
|
||||
$ deno run -A https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/flyio/generate_setupuri.ts
|
||||
export hostname=https://tiles-photograph-routine-groundwater.trycloudflare.com #Point to your vault
|
||||
export database=obsidiannotes #Please change as you like
|
||||
export passphrase=dfsapkdjaskdjasdas #Please change as you like
|
||||
export username=johndoe
|
||||
export password=abc123
|
||||
deno run -A https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/flyio/generate_setupuri.ts
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> What is the `passphrase`? Is it different from `uri_passphrase`?
|
||||
> Yes, the `passphrase` we have exported now is for an End-to-End Encryption passphrase.
|
||||
> And, `uri_passphrase` that used in the `generate_setupuri.ts` is a different one; for decrypting Set-up URI at using that.
|
||||
> Why: I (vorotamoroz) think that the passphrase of the Setup-URI should be different from the E2EE passphrase to prevent exposure caused by operational errors or the possibility of evil in our environment. On top of that, I believe that it is desirable for the Setup-URI to be random. Setup-URI is inevitably long, so it goes through the clipboard. I think that its passphrase should not go through the same path, so it should essentially be typed manually.
|
||||
> Hence, if we keep empty for uri_passphrase, generate_setupuri.ts generates an adjective-noun-randomnumber passphrase so that we can remember it without going through the clipboard.
|
||||
|
||||
You will then get the following output:
|
||||
|
||||
```bash
|
||||
obsidian://setuplivesync?settings=%5B%22tm2DpsOE74nJAryprZO2M93wF%2Fvg.......4b26ed33230729%22%5D
|
||||
|
||||
Your passphrase of Setup-URI is: patient-haze
|
||||
@@ -206,4 +225,4 @@ entryPoints:
|
||||
address: ":443"
|
||||
|
||||
...
|
||||
```
|
||||
```
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
# Terms used in this project
|
||||
# Notes on Terminology, Spelling, Vocabulary Conventions
|
||||
|
||||
## Terms
|
||||
## Spelling and Vocabulary conventions
|
||||
|
||||
### Chunks
|
||||
<!-- TBW, sorry for the draft! -->
|
||||
1. Almost all of the english words are written in British English. For example, "organisation" instead of "organization", "synchronisation" instead of "synchronization", etc. This convention originated from the author's personal preference but is now maintained for consistency.
|
||||
|
||||
2. Idiomatic terms, such as used in HTML, CSS, and JavaScript, are usually be aligned with the language used in the technology. For example, "color" instead of "colour", "program" instead of "programme", etc. Especially, terms which are used for attributes, properties, and methods are notable.
|
||||
|
||||
<!-- Please feel free to write any terms that should be mentioned. And please make pull request. I would love to fill the rest. -->
|
||||
<!-- ### Chunks -->
|
||||
3. We use `dialogue` in documentation for consistency. While `dialog` may appear in source code, particularly in class names, method names, and attributes (following technical conventions in No. 2), we consistently use `dialogue` for user-facing messages and general documentation text. This approach balances No. 1 with No. 2.
|
||||
|
||||
4. Contractions are not used. For example, "do not" instead of "don't", "cannot" instead of "can't", etc. especially `'d`.
|
||||
- We may encounter difficulties with tenses.
|
||||
|
||||
5. However, try using affirmative forms, `Discard` instead of `Do not keep`, `Continue` instead of `Do not stop`, etc.
|
||||
- Some languages, such as Japanese, have a different meaning for `yes` and `no` between affirmative and negative questions.
|
||||
|
||||
## Terminology
|
||||
|
||||
- Self-hosted LiveSync
|
||||
- This plug-in name. `Self-hosted` is one word.
|
||||
- LiveSync
|
||||
- Very confusing term.
|
||||
- As shorten-form of `Self-hosted LiveSync`.
|
||||
- As a name of synchronisation mode. This should be changed to `Continuos`, in contrast to `Periodic`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- 2024-02-15 -->
|
||||
# Tips and Troubleshooting
|
||||
<!-- 2025-02-18 -->
|
||||
|
||||
# Tips and Troubleshooting
|
||||
|
||||
- [Tips and Troubleshooting](#tips-and-troubleshooting)
|
||||
- [Notable bugs and fixes](#notable-bugs-and-fixes)
|
||||
@@ -25,14 +25,16 @@
|
||||
|
||||
<!-- - -->
|
||||
|
||||
|
||||
## Notable bugs and fixes
|
||||
|
||||
### Binary files get bigger on iOS
|
||||
|
||||
- Reported at: v0.20.x
|
||||
- Fixed at: v0.21.2 (Fixed but not reviewed)
|
||||
- Required action: larger files will not be fixed automatically, please perform `Verify and repair all files`. If our local database and storage are not matched, we will be asked to apply which one.
|
||||
|
||||
### Some setting name has been changed
|
||||
|
||||
- Fixed at: v0.22.6
|
||||
|
||||
| Previous name | New name |
|
||||
@@ -58,13 +60,16 @@ Therefore, experienced users (especially those stable enough not to have to rebu
|
||||
Please disable it when you have enough time.
|
||||
|
||||
### ZIP (or any extensions) files were not synchronised. Why?
|
||||
|
||||
It depends on Obsidian detects. May toggling `Detect all extensions` of `File and links` (setting of Obsidian) will help us.
|
||||
|
||||
### I hope to report the issue, but you said you needs `Report`. How to make it?
|
||||
|
||||
We can copy the report to the clipboard, by pressing the `Make report` button on the `Hatch` pane.
|
||||

|
||||
|
||||
### Where can I check the log?
|
||||
|
||||
We can launch the log pane by `Show log` on the command palette.
|
||||
And if you have troubled something, please enable the `Verbose Log` on the `General Setting` pane.
|
||||
|
||||
@@ -73,16 +78,20 @@ However, the logs would not be kept so long and cleared when restarted. If you w
|
||||

|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - Writing logs into the file will impact the performance.
|
||||
> - Please make sure that you have erased all your confidential information before reporting issue.
|
||||
|
||||
### Why are the logs volatile and ephemeral?
|
||||
|
||||
To avoid unexpected exposure to our confidential things.
|
||||
|
||||
### Some network logs are not written into the file.
|
||||
|
||||
Especially the CORS error will be reported as a general error to the plug-in for security reasons. So we cannot detect and log it. We are only able to investigate them by [Checking the network log](#checking-the-network-log).
|
||||
|
||||
### If a file were deleted or trimmed, the capacity of the database should be reduced, right?
|
||||
|
||||
No, even though if files were deleted, chunks were not deleted.
|
||||
Self-hosted LiveSync splits the files into multiple chunks and transfers only newly created. This behaviour enables us to less traffic. And, the chunks will be shared between the files to reduce the total usage of the database.
|
||||
|
||||
@@ -93,24 +102,42 @@ To shrink the database size, `Rebuild everything` only reliably and effectively.
|
||||
### How can I use the DevTools?
|
||||
|
||||
#### Checking the network log
|
||||
|
||||
1. Open the network pane.
|
||||
2. Find the requests marked in red.
|
||||

|
||||

|
||||
3. Capture the `Headers`, `Payload`, and, `Response`. **Please be sure to keep important information confidential**. If the `Response` contains secrets, you can omitted that.
|
||||
Note: Headers contains a some credentials. **The path of the request URL, Remote Address, authority, and authorization must be concealed.**
|
||||

|
||||
Note: Headers contains a some credentials. **The path of the request URL, Remote Address, authority, and authorization must be concealed.**
|
||||

|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<!-- Add here -->
|
||||
|
||||
### On the mobile device, cannot synchronise on the local network!
|
||||
|
||||
Obsidian mobile is not able to connect to the non-secure end-point, such as starting with `http://`. Make sure your URI of CouchDB. Also not able to use a self-signed certificate.
|
||||
|
||||
### I think that something bad happening on the vault...
|
||||
|
||||
Place `redflag.md` on top of the vault, and restart Obsidian. The most simple way is to create a new note and rename it to `redflag`. Of course, we can put it without Obsidian.
|
||||
|
||||
If there is `redflag.md`, Self-hosted LiveSync suspends all database and storage processes.
|
||||
|
||||
There are some options to use `redflag.md`.
|
||||
|
||||
| Filename | Human-Friendly Name | Description |
|
||||
| ------------- | ------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `redflag.md` | - | Suspends all processes. |
|
||||
| `redflag2.md` | `flag_rebuild.md` | Suspends all processes, and rebuild both local and remote databases by local files. |
|
||||
| `redflag3.md` | `flag_fetch.md` | Suspends all processes, discard the local database, and fetch from the remote again. |
|
||||
|
||||
When fetching everything remotely or performing a rebuild, restarting Obsidian is performed once for safety reasons. At that time, Self-hosted LiveSync uses these files to determine whether the process should be carried out.
|
||||
(The use of normal markdown files is a trick to externally force cancellation in the event of faults in the rebuild or fetch function itself, especially on mobile devices).
|
||||
This mechanism is also used for set-up. And just for information, these files are also not subject to synchronisation.
|
||||
|
||||
However, occasionally the deletion of files may fail. This should generally work normally after restarting Obsidian. (As far as I can observe).
|
||||
|
||||
## Tips
|
||||
|
||||
### How to resolve `Tweaks Mismatched of Changed`
|
||||
@@ -125,7 +152,7 @@ Following dialogue will be shown:
|
||||
|
||||
- If we want to propagate the setting of the device, we should choose `Update with mine`.
|
||||
- On other devices, we should choose `Use configured` to accept and use the configured configuration.
|
||||
- `Dismiss` can postpone a decision. However, we cannot synchronise until we have decided.
|
||||
- `Dismiss` can postpone a decision. However, we cannot synchronise until we have decided.
|
||||
|
||||
Rest assured that in most cases we can choose `Use configured`. (Unless you are certain that you have not changed the configuration).
|
||||
|
||||
@@ -133,16 +160,16 @@ If we see it for the first time, it reflects the settings of the device that has
|
||||
|
||||
<!-- Add here -->
|
||||
|
||||
|
||||
### Old tips
|
||||
- Rarely, a file in the database could be corrupted. The plugin will not write to local storage when a file looks corrupted. If a local version of the file is on your device, the corruption could be fixed by editing the local file and synchronizing it. But if the file does not exist on any of your devices, then it can not be rescued. In this case, you can delete these items from the settings dialog.
|
||||
- To stop the boot-up sequence (eg. for fixing problems on databases), you can put a `redflag.md` file (or directory) at the root of your vault.
|
||||
Tip for iOS: a redflag directory can be created at the root of the vault using the File application.
|
||||
- Also, with `redflag2.md` placed, we can automatically rebuild both the local and the remote databases during the boot-up sequence. With `redflag3.md`, we can discard only the local database and fetch from the remote again.
|
||||
- Q: The database is growing, how can I shrink it down?
|
||||
A: each of the docs is saved with their past 100 revisions for detecting and resolving conflicts. Picturing that one device has been offline for a while, and comes online again. The device has to compare its notes with the remotely saved ones. If there exists a historic revision in which the note used to be identical, it could be updated safely (like git fast-forward). Even if that is not in revision histories, we only have to check the differences after the revision that both devices commonly have. This is like git's conflict-resolving method. So, We have to make the database again like an enlarged git repo if you want to solve the root of the problem.
|
||||
- And more technical Information is in the [Technical Information](tech_info.md)
|
||||
- If you want to synchronize files without obsidian, you can use [filesystem-livesync](https://github.com/vrtmrz/filesystem-livesync).
|
||||
- WebClipper is also available on Chrome Web Store:[obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
|
||||
|
||||
- Rarely, a file in the database could be corrupted. The plugin will not write to local storage when a file looks corrupted. If a local version of the file is on your device, the corruption could be fixed by editing the local file and synchronizing it. But if the file does not exist on any of your devices, then it can not be rescued. In this case, you can delete these items from the settings dialog.
|
||||
- To stop the boot-up sequence (eg. for fixing problems on databases), you can put a `redflag.md` file (or directory) at the root of your vault.
|
||||
Tip for iOS: a redflag directory can be created at the root of the vault using the File application.
|
||||
- Also, with `redflag2.md` placed, we can automatically rebuild both the local and the remote databases during the boot-up sequence. With `redflag3.md`, we can discard only the local database and fetch from the remote again.
|
||||
- Q: The database is growing, how can I shrink it down?
|
||||
A: each of the docs is saved with their past 100 revisions for detecting and resolving conflicts. Picturing that one device has been offline for a while, and comes online again. The device has to compare its notes with the remotely saved ones. If there exists a historic revision in which the note used to be identical, it could be updated safely (like git fast-forward). Even if that is not in revision histories, we only have to check the differences after the revision that both devices commonly have. This is like git's conflict-resolving method. So, We have to make the database again like an enlarged git repo if you want to solve the root of the problem.
|
||||
- And more technical Information is in the [Technical Information](tech_info.md)
|
||||
- If you want to synchronize files without obsidian, you can use [filesystem-livesync](https://github.com/vrtmrz/filesystem-livesync).
|
||||
- WebClipper is also available on Chrome Web Store:[obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
|
||||
|
||||
Repo is here: [obsidian-livesync-webclip](https://github.com/vrtmrz/obsidian-livesync-webclip). (Docs are a work in progress.)
|
||||
|
||||
@@ -4,12 +4,13 @@ import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import sveltePlugin from "esbuild-svelte";
|
||||
import sveltePreprocess from "svelte-preprocess";
|
||||
import { sveltePreprocess } from "svelte-preprocess";
|
||||
import fs from "node:fs";
|
||||
// import terser from "terser";
|
||||
import { minify } from "terser";
|
||||
import inlineWorkerPlugin from "esbuild-plugin-inline-worker";
|
||||
import { terserOption } from "./terser.config.mjs";
|
||||
import path from "node:path";
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
const keepTest = true; //!prod;
|
||||
@@ -18,6 +19,46 @@ const manifestJson = JSON.parse(fs.readFileSync("./manifest.json") + "");
|
||||
const packageJson = JSON.parse(fs.readFileSync("./package.json") + "");
|
||||
const updateInfo = JSON.stringify(fs.readFileSync("./updates.md") + "");
|
||||
|
||||
const moduleAliasPlugin = {
|
||||
name: "module-alias",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /.(dev)(.ts|)$/ }, (args) => {
|
||||
// console.log(args.path);
|
||||
if (prod) {
|
||||
let prodTs = args.path.replace(".dev", ".prod");
|
||||
const statFile = prodTs.endsWith(".ts") ? prodTs : prodTs + ".ts";
|
||||
const realPath = path.join(args.resolveDir, statFile);
|
||||
console.log(`Checking ${statFile}`);
|
||||
if (fs.existsSync(realPath)) {
|
||||
console.log(`Replaced ${args.path} with ${prodTs}`);
|
||||
return {
|
||||
path: realPath,
|
||||
namespace: "file",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
build.onResolve({ filter: /.(platform)(.ts|)$/ }, (args) => {
|
||||
// console.log(args.path);
|
||||
if (prod) {
|
||||
let prodTs = args.path.replace(".platform", ".obsidian");
|
||||
const statFile = prodTs.endsWith(".ts") ? prodTs : prodTs + ".ts";
|
||||
const realPath = path.join(args.resolveDir, statFile);
|
||||
console.log(`Checking ${statFile}`);
|
||||
if (fs.existsSync(realPath)) {
|
||||
console.log(`Replaced ${args.path} with ${prodTs}`);
|
||||
return {
|
||||
path: realPath,
|
||||
namespace: "file",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** @type esbuild.Plugin[] */
|
||||
const plugins = [
|
||||
{
|
||||
@@ -26,10 +67,22 @@ const plugins = [
|
||||
let count = 0;
|
||||
build.onEnd(async (result) => {
|
||||
if (count++ === 0) {
|
||||
console.log("first build:", result);
|
||||
console.log("first build:");
|
||||
if (prod) {
|
||||
console.log("MetaFile:");
|
||||
if (result.metafile) {
|
||||
fs.writeFileSync("meta.json", JSON.stringify(result.metafile));
|
||||
let text = await esbuild.analyzeMetafile(result.metafile, {
|
||||
verbose: true,
|
||||
});
|
||||
// console.log(text);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("subsequent build:");
|
||||
}
|
||||
const filename = `meta-${prod ? "prod" : "dev"}.json`;
|
||||
await fs.promises.writeFile(filename, JSON.stringify(result.metafile, null, 2));
|
||||
if (prod) {
|
||||
console.log("Performing terser");
|
||||
const src = fs.readFileSync("./main_org.js").toString();
|
||||
@@ -47,7 +100,22 @@ const plugins = [
|
||||
},
|
||||
];
|
||||
|
||||
const externals = ["obsidian", "electron", "crypto", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr"];
|
||||
const externals = [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"crypto",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
];
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: "// Leave it all to terser",
|
||||
@@ -66,6 +134,7 @@ const context = await esbuild.context({
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
platform: "browser",
|
||||
metafile: true,
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: false,
|
||||
outfile: "main_org.js",
|
||||
@@ -77,6 +146,7 @@ const context = await esbuild.context({
|
||||
dropLabels: prod && !keepTest ? ["TEST", "DEV"] : [],
|
||||
// keepNames: true,
|
||||
plugins: [
|
||||
moduleAliasPlugin,
|
||||
inlineWorkerPlugin({
|
||||
external: externals,
|
||||
treeShaking: true,
|
||||
|
||||
100
eslint.config.mjs
Normal file
100
eslint.config.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import _import from "eslint-plugin-import";
|
||||
import { fixupPluginRules } from "@eslint/compat";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"**/node_modules/*",
|
||||
"**/jest.config.js",
|
||||
"src/lib/coverage",
|
||||
"src/lib/browsertest",
|
||||
"**/test.ts",
|
||||
"**/tests.ts",
|
||||
"**/**test.ts",
|
||||
"**/**.test.ts",
|
||||
"**/esbuild.*.mjs",
|
||||
"**/terser.*.mjs",
|
||||
"**/node_modules",
|
||||
"**/build",
|
||||
"**/.eslintrc.js.bak",
|
||||
"src/lib/src/patches/pouchdb-utils",
|
||||
"**/esbuild.config.mjs",
|
||||
"**/rollup.config.js",
|
||||
"modules/octagonal-wheels/rollup.config.js",
|
||||
"modules/octagonal-wheels/dist/**/*",
|
||||
"src/lib/test",
|
||||
"src/lib/src/cli",
|
||||
"**/main.js",
|
||||
"src/lib/apps/webpeer/dist",
|
||||
"src/lib/apps/webpeer/svelte.config.js",
|
||||
],
|
||||
},
|
||||
...compat.extends(
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
),
|
||||
{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
svelte,
|
||||
import: fixupPluginRules(_import),
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 5,
|
||||
sourceType: "module",
|
||||
|
||||
parserOptions: {
|
||||
project: ["tsconfig.json"],
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
args: "none",
|
||||
},
|
||||
],
|
||||
|
||||
"no-unused-labels": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"require-await": "error",
|
||||
"@typescript-eslint/require-await": "warn",
|
||||
"@typescript-eslint/no-misused-promises": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "warn",
|
||||
"no-async-promise-executor": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
|
||||
"no-constant-condition": [
|
||||
"error",
|
||||
{
|
||||
checkLoops: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.24.0.dev-rc3",
|
||||
"version": "0.24.0",
|
||||
"minAppVersion": "0.9.12",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.23.23",
|
||||
"version": "0.24.23",
|
||||
"minAppVersion": "0.9.12",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
10517
package-lock.json
generated
10517
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
50
package.json
50
package.json
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.24.0.dev-rc3",
|
||||
"version": "0.24.23",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"bakei18n": "npx tsx ./src/lib/_tools/bakei18n.ts",
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"build": "npm run bakei18n && node esbuild.config.mjs production",
|
||||
"buildDev": "node esbuild.config.mjs dev",
|
||||
"lint": "eslint src",
|
||||
"svelte-check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"tsc-check": "tsc --noEmit",
|
||||
"pretty": "npm run prettyNoWrite -- --write --log-level error",
|
||||
"prettyCheck": "npm run prettyNoWrite -- --check",
|
||||
"prettyNoWrite": "prettier --config ./.prettierrc \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
|
||||
"check": "npm run lint && npm run svelte-check && npm run tsc-check"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -18,6 +22,9 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@chialab/esbuild-plugin-worker": "^0.18.1",
|
||||
"@eslint/compat": "^1.2.6",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/node": "^22.5.4",
|
||||
@@ -29,17 +36,17 @@
|
||||
"@types/pouchdb-mapreduce": "^6.1.10",
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.4.0",
|
||||
"@typescript-eslint/parser": "^8.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.24.1",
|
||||
"@typescript-eslint/parser": "^8.24.1",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "0.23.1",
|
||||
"esbuild-svelte": "^0.8.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.30.0",
|
||||
"esbuild": "0.24.2",
|
||||
"esbuild-svelte": "^0.9.0",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"events": "^3.3.0",
|
||||
"obsidian": "^1.6.6",
|
||||
"postcss": "^8.4.45",
|
||||
"obsidian": "^1.7.2",
|
||||
"postcss": "^8.5.2",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
@@ -51,12 +58,14 @@
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-preprocess": "^6.0.2",
|
||||
"terser": "^5.31.6",
|
||||
"prettier": "^3.5.1",
|
||||
"svelte": "^5.20.1",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.5.4"
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.645.0",
|
||||
@@ -66,11 +75,12 @@
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.0",
|
||||
"idb": "^8.0.2",
|
||||
"minimatch": "^10.0.1",
|
||||
"octagonal-wheels": "^0.1.15",
|
||||
"svelte-check": "^4.0.4",
|
||||
"xxhash-wasm": "0.4.2",
|
||||
"octagonal-wheels": "^0.1.25",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"svelte-check": "^4.1.4",
|
||||
"trystero": "^0.20.1",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
28
src/common/SvelteItemView.ts
Normal file
28
src/common/SvelteItemView.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ItemView } from "obsidian";
|
||||
import { type mount, unmount } from "svelte";
|
||||
|
||||
export abstract class SvelteItemView extends ItemView {
|
||||
abstract instantiateComponent(target: HTMLElement): ReturnType<typeof mount> | Promise<ReturnType<typeof mount>>;
|
||||
component?: ReturnType<typeof mount>;
|
||||
async onOpen() {
|
||||
await super.onOpen();
|
||||
this.contentEl.empty();
|
||||
await this._dismountComponent();
|
||||
this.component = await this.instantiateComponent(this.contentEl);
|
||||
return;
|
||||
}
|
||||
async _dismountComponent() {
|
||||
if (this.component) {
|
||||
await unmount(this.component);
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
async onClose() {
|
||||
await super.onClose();
|
||||
if (this.component) {
|
||||
await unmount(this.component);
|
||||
this.component = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,45 @@
|
||||
export const EVENT_LAYOUT_READY = "layout-ready";
|
||||
import { eventHub } from "../lib/src/hub/hub";
|
||||
import type ObsidianLiveSyncPlugin from "../main";
|
||||
|
||||
export const EVENT_PLUGIN_LOADED = "plugin-loaded";
|
||||
export const EVENT_PLUGIN_UNLOADED = "plugin-unloaded";
|
||||
export const EVENT_SETTING_SAVED = "setting-saved";
|
||||
export const EVENT_FILE_RENAMED = "file-renamed";
|
||||
export const EVENT_FILE_SAVED = "file-saved";
|
||||
export const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed";
|
||||
|
||||
export const EVENT_LOG_ADDED = "log-added";
|
||||
|
||||
export const EVENT_REQUEST_OPEN_SETTINGS = "request-open-settings";
|
||||
export const EVENT_REQUEST_OPEN_SETTING_WIZARD = "request-open-setting-wizard";
|
||||
export const EVENT_REQUEST_OPEN_SETUP_URI = "request-open-setup-uri";
|
||||
export const EVENT_REQUEST_COPY_SETUP_URI = "request-copy-setup-uri";
|
||||
|
||||
export const EVENT_REQUEST_SHOW_HISTORY = "show-history";
|
||||
export const EVENT_REQUEST_SHOW_SETUP_QR = "request-show-setup-qr";
|
||||
|
||||
export const EVENT_REQUEST_RELOAD_SETTING_TAB = "reload-setting-tab";
|
||||
|
||||
export const EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG = "request-open-plugin-sync-dialog";
|
||||
|
||||
export const EVENT_REQUEST_OPEN_P2P = "request-open-p2p";
|
||||
export const EVENT_REQUEST_CLOSE_P2P = "request-close-p2p";
|
||||
|
||||
export const EVENT_REQUEST_RUN_DOCTOR = "request-run-doctor";
|
||||
|
||||
// export const EVENT_FILE_CHANGED = "file-changed";
|
||||
|
||||
import { eventHub } from "../lib/src/hub/hub";
|
||||
// TODO: Add overloads for the emit method to allow for type checking
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
[EVENT_PLUGIN_LOADED]: ObsidianLiveSyncPlugin;
|
||||
[EVENT_PLUGIN_UNLOADED]: undefined;
|
||||
[EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTINGS]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTING_WIZARD]: undefined;
|
||||
[EVENT_REQUEST_RELOAD_SETTING_TAB]: undefined;
|
||||
[EVENT_LEAF_ACTIVE_CHANGED]: undefined;
|
||||
[EVENT_REQUEST_CLOSE_P2P]: undefined;
|
||||
[EVENT_REQUEST_OPEN_P2P]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETUP_URI]: undefined;
|
||||
[EVENT_REQUEST_COPY_SETUP_URI]: undefined;
|
||||
[EVENT_REQUEST_SHOW_SETUP_QR]: undefined;
|
||||
[EVENT_REQUEST_RUN_DOCTOR]: string;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "../lib/src/events/coreEvents.ts";
|
||||
export { eventHub };
|
||||
|
||||
|
||||
12
src/common/obsidianEvents.ts
Normal file
12
src/common/obsidianEvents.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { TFile } from "../deps";
|
||||
import type { FilePathWithPrefix, LoadedEntry } from "../lib/src/common/types";
|
||||
|
||||
export const EVENT_REQUEST_SHOW_HISTORY = "show-history";
|
||||
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
[EVENT_REQUEST_SHOW_HISTORY]:
|
||||
| { file: TFile; fileOnDB: LoadedEntry }
|
||||
| { file: FilePathWithPrefix; fileOnDB: LoadedEntry };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { type PluginManifest, TFile } from "../deps.ts";
|
||||
import { type DatabaseEntry, type EntryBody, type FilePath, type UXFileInfoStub, type UXInternalFileInfoStub } from "../lib/src/common/types.ts";
|
||||
import {
|
||||
type DatabaseEntry,
|
||||
type EntryBody,
|
||||
type FilePath,
|
||||
type UXFileInfoStub,
|
||||
type UXInternalFileInfoStub,
|
||||
} from "../lib/src/common/types.ts";
|
||||
|
||||
export interface PluginDataEntry extends DatabaseEntry {
|
||||
deviceVaultName: string;
|
||||
@@ -55,15 +61,15 @@ export type FileEventArgs = {
|
||||
cache?: CacheData;
|
||||
oldPath?: string;
|
||||
ctx?: any;
|
||||
}
|
||||
};
|
||||
export type FileEventItem = {
|
||||
type: FileEventType,
|
||||
args: FileEventArgs,
|
||||
key: string,
|
||||
skipBatchWait?: boolean,
|
||||
cancelled?: boolean,
|
||||
batched?: boolean
|
||||
}
|
||||
type: FileEventType;
|
||||
args: FileEventArgs;
|
||||
key: string;
|
||||
skipBatchWait?: boolean;
|
||||
cancelled?: boolean;
|
||||
batched?: boolean;
|
||||
};
|
||||
|
||||
// Hidden items (Now means `chunk`)
|
||||
export const CHeader = "h:";
|
||||
@@ -82,4 +88,4 @@ export const ICXHeader = "ix:";
|
||||
|
||||
export const FileWatchEventQueueMax = 10;
|
||||
export const configURIBase = "obsidian://setuplivesync?settings=";
|
||||
|
||||
export const configURIBaseQR = "obsidian://setuplivesync?settingsQR=";
|
||||
|
||||
@@ -1,20 +1,47 @@
|
||||
import { normalizePath, Platform, TAbstractFile, type RequestUrlParam, requestUrl } from "../deps.ts";
|
||||
import { path2id_base, id2path_base, isValidFilenameInLinux, isValidFilenameInDarwin, isValidFilenameInWidows, isValidFilenameInAndroid, stripAllPrefixes } from "../lib/src/string_and_binary/path.ts";
|
||||
import {
|
||||
path2id_base,
|
||||
id2path_base,
|
||||
isValidFilenameInLinux,
|
||||
isValidFilenameInDarwin,
|
||||
isValidFilenameInWidows,
|
||||
isValidFilenameInAndroid,
|
||||
stripAllPrefixes,
|
||||
} from "../lib/src/string_and_binary/path.ts";
|
||||
|
||||
import { Logger } from "../lib/src/common/logger.ts";
|
||||
import { LOG_LEVEL_VERBOSE, type AnyEntry, type DocumentID, type EntryHasPath, type FilePath, type FilePathWithPrefix, type UXFileInfo, type UXFileInfoStub } from "../lib/src/common/types.ts";
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type AnyEntry,
|
||||
type DocumentID,
|
||||
type EntryHasPath,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type UXFileInfo,
|
||||
type UXFileInfoStub,
|
||||
} from "../lib/src/common/types.ts";
|
||||
import { CHeader, ICHeader, ICHeaderLength, ICXHeader, PSCHeader } from "./types.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
import { writeString } from "../lib/src/string_and_binary/convert.ts";
|
||||
import { fireAndForget } from "../lib/src/common/utils.ts";
|
||||
import { sameChangePairs } from "./stores.ts";
|
||||
import type { KeyValueDatabase } from "./KeyValueDB.ts";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { EVENT_PLUGIN_UNLOADED, eventHub } from "./events.ts";
|
||||
import { promiseWithResolver, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
|
||||
export { scheduleTask, setPeriodicTask, cancelTask, cancelAllTasks, cancelPeriodicTask, cancelAllPeriodicTask, } from "../lib/src/concurrency/task.ts";
|
||||
export { scheduleTask, cancelTask, cancelAllTasks } from "../lib/src/concurrency/task.ts";
|
||||
|
||||
// For backward compatibility, using the path for determining id.
|
||||
// Only CouchDB unacceptable ID (that starts with an underscore) has been prefixed with "/".
|
||||
// The first slash will be deleted when the path is normalized.
|
||||
export async function path2id(filename: FilePathWithPrefix | FilePath, obfuscatePassphrase: string | false, caseInsensitive: boolean): Promise<DocumentID> {
|
||||
export async function path2id(
|
||||
filename: FilePathWithPrefix | FilePath,
|
||||
obfuscatePassphrase: string | false,
|
||||
caseInsensitive: boolean
|
||||
): Promise<DocumentID> {
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
@@ -35,7 +62,6 @@ export function id2path(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefi
|
||||
}
|
||||
export function getPath(entry: AnyEntry) {
|
||||
return id2path(entry._id, entry);
|
||||
|
||||
}
|
||||
export function getPathWithoutPrefix(entry: AnyEntry) {
|
||||
const f = getPath(entry);
|
||||
@@ -45,11 +71,26 @@ export function getPathWithoutPrefix(entry: AnyEntry) {
|
||||
export function getPathFromTFile(file: TAbstractFile) {
|
||||
return file.path as FilePath;
|
||||
}
|
||||
|
||||
export function isInternalFile(file: UXFileInfoStub | string | FilePathWithPrefix) {
|
||||
if (typeof file == "string") return file.startsWith(ICHeader);
|
||||
if (file.isInternal) return true;
|
||||
return false;
|
||||
}
|
||||
export function getPathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix) {
|
||||
if (typeof file == "string") return file as FilePathWithPrefix;
|
||||
return file.path;
|
||||
}
|
||||
|
||||
export function getStoragePathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix) {
|
||||
if (typeof file == "string") return stripAllPrefixes(file as FilePathWithPrefix);
|
||||
return stripAllPrefixes(file.path);
|
||||
}
|
||||
export function getDatabasePathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix) {
|
||||
if (typeof file == "string" && file.startsWith(ICXHeader)) return file as FilePathWithPrefix;
|
||||
const prefix = isInternalFile(file) ? ICHeader : "";
|
||||
if (typeof file == "string") return (prefix + stripAllPrefixes(file as FilePathWithPrefix)) as FilePathWithPrefix;
|
||||
return (prefix + stripAllPrefixes(file.path)) as FilePathWithPrefix;
|
||||
}
|
||||
|
||||
const memos: { [key: string]: any } = {};
|
||||
export function memoObject<T>(key: string, obj: T): T {
|
||||
@@ -59,7 +100,7 @@ export function memoObject<T>(key: string, obj: T): T {
|
||||
export async function memoIfNotExist<T>(key: string, func: () => T | Promise<T>): Promise<T> {
|
||||
if (!(key in memos)) {
|
||||
const w = func();
|
||||
const v = w instanceof Promise ? (await w) : w;
|
||||
const v = w instanceof Promise ? await w : w;
|
||||
memos[key] = v;
|
||||
}
|
||||
return memos[key] as T;
|
||||
@@ -75,7 +116,6 @@ export function disposeMemoObject(key: string) {
|
||||
delete memos[key];
|
||||
}
|
||||
|
||||
|
||||
export function isValidPath(filename: string) {
|
||||
if (Platform.isDesktop) {
|
||||
// if(Platform.isMacOS) return isValidFilenameInDarwin(filename);
|
||||
@@ -94,11 +134,10 @@ export function trimPrefix(target: string, prefix: string) {
|
||||
return target.startsWith(prefix) ? target.substring(prefix.length) : target;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* returns is internal chunk of file
|
||||
* @param id ID
|
||||
* @returns
|
||||
* @returns
|
||||
*/
|
||||
export function isInternalMetadata(id: FilePath | FilePathWithPrefix | DocumentID): boolean {
|
||||
return id.startsWith(ICHeader);
|
||||
@@ -107,7 +146,7 @@ export function stripInternalMetadataPrefix<T extends FilePath | FilePathWithPre
|
||||
return id.substring(ICHeaderLength) as T;
|
||||
}
|
||||
export function id2InternalMetadataId(id: DocumentID): DocumentID {
|
||||
return ICHeader + id as DocumentID;
|
||||
return (ICHeader + id) as DocumentID;
|
||||
}
|
||||
|
||||
// const CHeaderLength = CHeader.length;
|
||||
@@ -122,14 +161,16 @@ export function isCustomisationSyncMetadata(str: string): boolean {
|
||||
return str.startsWith(ICXHeader);
|
||||
}
|
||||
|
||||
|
||||
export class PeriodicProcessor {
|
||||
_process: () => Promise<any>;
|
||||
_timer?: number;
|
||||
_timer?: number = undefined;
|
||||
_plugin: ObsidianLiveSyncPlugin;
|
||||
constructor(plugin: ObsidianLiveSyncPlugin, process: () => Promise<any>) {
|
||||
this._plugin = plugin;
|
||||
this._process = process;
|
||||
eventHub.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
||||
this.disable();
|
||||
});
|
||||
}
|
||||
async process() {
|
||||
try {
|
||||
@@ -141,12 +182,16 @@ export class PeriodicProcessor {
|
||||
enable(interval: number) {
|
||||
this.disable();
|
||||
if (interval == 0) return;
|
||||
this._timer = window.setInterval(() => fireAndForget(async () => {
|
||||
await this.process();
|
||||
if (this._plugin._unloaded) {
|
||||
this.disable();
|
||||
}
|
||||
}), interval);
|
||||
this._timer = window.setInterval(
|
||||
() =>
|
||||
fireAndForget(async () => {
|
||||
await this.process();
|
||||
if (this._plugin.$$isUnloaded()) {
|
||||
this.disable();
|
||||
}
|
||||
}),
|
||||
interval
|
||||
);
|
||||
this._plugin.registerInterval(this._timer);
|
||||
}
|
||||
disable() {
|
||||
@@ -157,11 +202,21 @@ export class PeriodicProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
export const _requestToCouchDBFetch = async (baseUri: string, username: string, password: string, path?: string, body?: string | any, method?: string) => {
|
||||
export const _requestToCouchDBFetch = async (
|
||||
baseUri: string,
|
||||
username: string,
|
||||
password: string,
|
||||
path?: string,
|
||||
body?: string | any,
|
||||
method?: string
|
||||
) => {
|
||||
const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]);
|
||||
const encoded = window.btoa(utf8str);
|
||||
const authHeader = "Basic " + encoded;
|
||||
const transformedHeaders: Record<string, string> = { authorization: authHeader, "content-type": "application/json" };
|
||||
const transformedHeaders: Record<string, string> = {
|
||||
authorization: authHeader,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
const uri = `${baseUri}/${path}`;
|
||||
const requestParam = {
|
||||
url: uri,
|
||||
@@ -171,9 +226,17 @@ export const _requestToCouchDBFetch = async (baseUri: string, username: string,
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
return await fetch(uri, requestParam);
|
||||
}
|
||||
};
|
||||
|
||||
export const _requestToCouchDB = async (baseUri: string, username: string, password: string, origin: string, path?: string, body?: any, method?: string) => {
|
||||
export const _requestToCouchDB = async (
|
||||
baseUri: string,
|
||||
username: string,
|
||||
password: string,
|
||||
origin: string,
|
||||
path?: string,
|
||||
body?: any,
|
||||
method?: string
|
||||
) => {
|
||||
const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]);
|
||||
const encoded = window.btoa(utf8str);
|
||||
const authHeader = "Basic " + encoded;
|
||||
@@ -187,23 +250,32 @@ export const _requestToCouchDB = async (baseUri: string, username: string, passw
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
return await requestUrl(requestParam);
|
||||
}
|
||||
export const requestToCouchDB = async (baseUri: string, username: string, password: string, origin: string = "", key?: string, body?: string, method?: string) => {
|
||||
};
|
||||
export const requestToCouchDB = async (
|
||||
baseUri: string,
|
||||
username: string,
|
||||
password: string,
|
||||
origin: string = "",
|
||||
key?: string,
|
||||
body?: string,
|
||||
method?: string
|
||||
) => {
|
||||
const uri = `_node/_local/_config${key ? "/" + key : ""}`;
|
||||
return await _requestToCouchDB(baseUri, username, password, origin, uri, body, method);
|
||||
};
|
||||
|
||||
|
||||
export const BASE_IS_NEW = Symbol("base");
|
||||
export const TARGET_IS_NEW = Symbol("target");
|
||||
export const EVEN = Symbol("even");
|
||||
|
||||
|
||||
// Why 2000? : ZIP FILE Does not have enough resolution.
|
||||
const resolution = 2000;
|
||||
export function compareMTime(baseMTime: number, targetMTime: number): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
const truncatedBaseMTime = (~~(baseMTime / resolution)) * resolution;
|
||||
const truncatedTargetMTime = (~~(targetMTime / resolution)) * resolution;
|
||||
export function compareMTime(
|
||||
baseMTime: number,
|
||||
targetMTime: number
|
||||
): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
const truncatedBaseMTime = ~~(baseMTime / resolution) * resolution;
|
||||
const truncatedTargetMTime = ~~(targetMTime / resolution) * resolution;
|
||||
// Logger(`Resolution MTime ${truncatedBaseMTime} and ${truncatedTargetMTime} `, LOG_LEVEL_VERBOSE);
|
||||
if (truncatedBaseMTime == truncatedTargetMTime) return EVEN;
|
||||
if (truncatedBaseMTime > truncatedTargetMTime) return BASE_IS_NEW;
|
||||
@@ -211,30 +283,43 @@ export function compareMTime(baseMTime: number, targetMTime: number): typeof BAS
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
|
||||
function getKey(file: AnyEntry | string | UXFileInfoStub) {
|
||||
const key = typeof file == "string" ? file : stripAllPrefixes(file.path);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function markChangesAreSame(file: AnyEntry | string | UXFileInfoStub, mtime1: number, mtime2: number) {
|
||||
if (mtime1 === mtime2) return true;
|
||||
const key = typeof file == "string" ? file : "_id" in file ? file._id : file.path;
|
||||
const key = getKey(file);
|
||||
const pairs = sameChangePairs.get(key, []) || [];
|
||||
if (pairs.some(e => e == mtime1 || e == mtime2)) {
|
||||
if (pairs.some((e) => e == mtime1 || e == mtime2)) {
|
||||
sameChangePairs.set(key, [...new Set([...pairs, mtime1, mtime2])]);
|
||||
} else {
|
||||
sameChangePairs.set(key, [mtime1, mtime2]);
|
||||
}
|
||||
}
|
||||
|
||||
export function unmarkChanges(file: AnyEntry | string | UXFileInfoStub) {
|
||||
const key = getKey(file);
|
||||
sameChangePairs.delete(key);
|
||||
}
|
||||
export function isMarkedAsSameChanges(file: UXFileInfoStub | AnyEntry | string, mtimes: number[]) {
|
||||
const key = typeof file == "string" ? file : "_id" in file ? file._id : file.path;
|
||||
const key = getKey(file);
|
||||
const pairs = sameChangePairs.get(key, []) || [];
|
||||
if (mtimes.every(e => pairs.indexOf(e) !== -1)) {
|
||||
if (mtimes.every((e) => pairs.indexOf(e) !== -1)) {
|
||||
return EVEN;
|
||||
}
|
||||
}
|
||||
export function compareFileFreshness(baseFile: UXFileInfoStub | AnyEntry | undefined, checkTarget: UXFileInfo | AnyEntry | undefined): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
export function compareFileFreshness(
|
||||
baseFile: UXFileInfoStub | AnyEntry | undefined,
|
||||
checkTarget: UXFileInfo | AnyEntry | undefined
|
||||
): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
if (baseFile === undefined && checkTarget == undefined) return EVEN;
|
||||
if (baseFile == undefined) return TARGET_IS_NEW;
|
||||
if (checkTarget == undefined) return BASE_IS_NEW;
|
||||
|
||||
const modifiedBase = "stat" in baseFile ? baseFile?.stat?.mtime ?? 0 : baseFile?.mtime ?? 0;
|
||||
const modifiedTarget = "stat" in checkTarget ? checkTarget?.stat?.mtime ?? 0 : checkTarget?.mtime ?? 0;
|
||||
const modifiedBase = "stat" in baseFile ? (baseFile?.stat?.mtime ?? 0) : (baseFile?.mtime ?? 0);
|
||||
const modifiedTarget = "stat" in checkTarget ? (checkTarget?.stat?.mtime ?? 0) : (checkTarget?.mtime ?? 0);
|
||||
|
||||
if (modifiedBase && modifiedTarget && isMarkedAsSameChanges(baseFile, [modifiedBase, modifiedTarget])) {
|
||||
return EVEN;
|
||||
@@ -242,21 +327,27 @@ export function compareFileFreshness(baseFile: UXFileInfoStub | AnyEntry | undef
|
||||
return compareMTime(modifiedBase, modifiedTarget);
|
||||
}
|
||||
|
||||
const _cached = new Map<string, {
|
||||
value: any;
|
||||
context: Map<string, any>;
|
||||
}>();
|
||||
const _cached = new Map<
|
||||
string,
|
||||
{
|
||||
value: any;
|
||||
context: Map<string, any>;
|
||||
}
|
||||
>();
|
||||
|
||||
export type MemoOption = {
|
||||
key: string;
|
||||
forceUpdate?: boolean;
|
||||
validator?: (context: Map<string, any>) => boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export function useMemo<T>({ key, forceUpdate, validator }: MemoOption, updateFunc: (context: Map<string, any>, prev: T) => T): T {
|
||||
export function useMemo<T>(
|
||||
{ key, forceUpdate, validator }: MemoOption,
|
||||
updateFunc: (context: Map<string, any>, prev: T) => T
|
||||
): T {
|
||||
const cached = _cached.get(key);
|
||||
const context = cached?.context || new Map<string, any>();
|
||||
if (cached && !forceUpdate && (!validator || validator && !validator(context))) {
|
||||
if (cached && !forceUpdate && (!validator || (validator && !validator(context)))) {
|
||||
return cached.value;
|
||||
}
|
||||
const value = updateFunc(context, cached?.value);
|
||||
@@ -267,11 +358,14 @@ export function useMemo<T>({ key, forceUpdate, validator }: MemoOption, updateFu
|
||||
}
|
||||
|
||||
// const _static = new Map<string, any>();
|
||||
const _staticObj = new Map<string, {
|
||||
value: any
|
||||
}>();
|
||||
const _staticObj = new Map<
|
||||
string,
|
||||
{
|
||||
value: any;
|
||||
}
|
||||
>();
|
||||
|
||||
export function useStatic<T>(key: string): { value: (T | undefined) };
|
||||
export function useStatic<T>(key: string): { value: T | undefined };
|
||||
export function useStatic<T>(key: string, initial: T): { value: T };
|
||||
export function useStatic<T>(key: string, initial?: T) {
|
||||
// if (!_static.has(key) && initial) {
|
||||
@@ -288,9 +382,9 @@ export function useStatic<T>(key: string, initial?: T) {
|
||||
return this._buf as T;
|
||||
},
|
||||
set value(value: T) {
|
||||
this._buf = value
|
||||
}
|
||||
}
|
||||
this._buf = value;
|
||||
},
|
||||
};
|
||||
_staticObj.set(key, obj);
|
||||
return obj;
|
||||
}
|
||||
@@ -308,6 +402,255 @@ export function displayRev(rev: string) {
|
||||
return `${number}-${hash.substring(0, 6)}`;
|
||||
}
|
||||
|
||||
// export function getPathFromUXFileInfo(file: UXFileInfoStub | UXFileInfo | string) {
|
||||
// return (typeof file == "string" ? file : file.path) as FilePathWithPrefix;
|
||||
// }
|
||||
type DocumentProps = {
|
||||
id: DocumentID;
|
||||
rev?: string;
|
||||
prefixedPath: FilePathWithPrefix;
|
||||
path: FilePath;
|
||||
isDeleted: boolean;
|
||||
revDisplay: string;
|
||||
shortenedId: string;
|
||||
shortenedPath: string;
|
||||
};
|
||||
|
||||
export function getDocProps(doc: AnyEntry): DocumentProps {
|
||||
const id = doc._id;
|
||||
const shortenedId = id.substring(0, 10);
|
||||
const prefixedPath = getPath(doc);
|
||||
const path = stripAllPrefixes(prefixedPath);
|
||||
const rev = doc._rev;
|
||||
const revDisplay = rev ? displayRev(rev) : "0-NOREVS";
|
||||
// const prefix = prefixedPath.substring(0, prefixedPath.length - path.length);
|
||||
const shortenedPath = path.substring(0, 10);
|
||||
const isDeleted = doc._deleted || doc.deleted || false;
|
||||
return { id, rev, revDisplay, prefixedPath, path, isDeleted, shortenedId, shortenedPath };
|
||||
}
|
||||
|
||||
export function getLogLevel(showNotice: boolean) {
|
||||
return showNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
}
|
||||
|
||||
export type MapLike<K, V> = {
|
||||
set(key: K, value: V): Map<K, V>;
|
||||
clear(): void;
|
||||
delete(key: K): boolean;
|
||||
get(key: K): V | undefined;
|
||||
has(key: K): boolean;
|
||||
keys: () => IterableIterator<K>;
|
||||
get size(): number;
|
||||
};
|
||||
|
||||
export async function autosaveCache<K, V>(db: KeyValueDatabase, mapKey: string): Promise<MapLike<K, V>> {
|
||||
const savedData = (await db.get<Map<K, V>>(mapKey)) ?? new Map<K, V>();
|
||||
const _commit = () => {
|
||||
try {
|
||||
scheduleTask("commit-map-save-" + mapKey, 250, async () => {
|
||||
await db.set(mapKey, savedData);
|
||||
});
|
||||
} catch {
|
||||
// NO OP.
|
||||
}
|
||||
};
|
||||
return {
|
||||
set(key: K, value: V) {
|
||||
const modified = savedData.get(key) !== value;
|
||||
const result = savedData.set(key, value);
|
||||
if (modified) {
|
||||
_commit();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
clear(): void {
|
||||
savedData.clear();
|
||||
_commit();
|
||||
},
|
||||
delete(key: K): boolean {
|
||||
const result = savedData.delete(key);
|
||||
if (result) {
|
||||
_commit();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
get(key: K): V | undefined {
|
||||
return savedData.get(key);
|
||||
},
|
||||
has(key) {
|
||||
return savedData.has(key);
|
||||
},
|
||||
keys() {
|
||||
return savedData.keys();
|
||||
},
|
||||
get size() {
|
||||
return savedData.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function onlyInNTimes(n: number, proc: (progress: number) => any) {
|
||||
let counter = 0;
|
||||
return function () {
|
||||
if (counter++ % n == 0) {
|
||||
proc(counter);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const waitingTasks = {} as Record<string, { task?: PromiseWithResolvers<any>; previous: number; leastNext: number }>;
|
||||
|
||||
export function rateLimitedSharedExecution<T>(key: string, interval: number, proc: () => Promise<T>): Promise<T> {
|
||||
if (!(key in waitingTasks)) {
|
||||
waitingTasks[key] = { task: undefined, previous: 0, leastNext: 0 };
|
||||
}
|
||||
if (waitingTasks[key].task) {
|
||||
// Extend the previous execution time.
|
||||
waitingTasks[key].leastNext = Date.now() + interval;
|
||||
return waitingTasks[key].task.promise;
|
||||
}
|
||||
|
||||
const previous = waitingTasks[key].previous;
|
||||
|
||||
const delay = previous == 0 ? 0 : Math.max(interval - (Date.now() - previous), 0);
|
||||
|
||||
const task = promiseWithResolver<T>();
|
||||
void task.promise.finally(() => {
|
||||
if (waitingTasks[key].task === task) {
|
||||
waitingTasks[key].task = undefined;
|
||||
waitingTasks[key].previous = Math.max(Date.now(), waitingTasks[key].leastNext);
|
||||
}
|
||||
});
|
||||
waitingTasks[key] = {
|
||||
task,
|
||||
previous: Date.now(),
|
||||
leastNext: Date.now() + interval,
|
||||
};
|
||||
void scheduleTask("thin-out-" + key, delay, async () => {
|
||||
try {
|
||||
task.resolve(await proc());
|
||||
} catch (ex) {
|
||||
task.reject(ex);
|
||||
}
|
||||
});
|
||||
return task.promise;
|
||||
}
|
||||
export function updatePreviousExecutionTime(key: string, timeDelta: number = 0) {
|
||||
if (!(key in waitingTasks)) {
|
||||
waitingTasks[key] = { task: undefined, previous: 0, leastNext: 0 };
|
||||
}
|
||||
waitingTasks[key].leastNext = Math.max(Date.now() + timeDelta, waitingTasks[key].leastNext);
|
||||
}
|
||||
|
||||
const prefixMapObject = {
|
||||
s: {
|
||||
1: "V",
|
||||
2: "W",
|
||||
3: "X",
|
||||
4: "Y",
|
||||
5: "Z",
|
||||
},
|
||||
o: {
|
||||
1: "v",
|
||||
2: "w",
|
||||
3: "x",
|
||||
4: "y",
|
||||
5: "z",
|
||||
},
|
||||
} as Record<string, Record<number, string>>;
|
||||
|
||||
const decodePrefixMapObject = Object.fromEntries(
|
||||
Object.entries(prefixMapObject).flatMap(([prefix, map]) =>
|
||||
Object.entries(map).map(([len, char]) => [char, { prefix, len: parseInt(len) }])
|
||||
)
|
||||
);
|
||||
|
||||
const prefixMapNumber = {
|
||||
n: {
|
||||
1: "a",
|
||||
2: "b",
|
||||
3: "c",
|
||||
4: "d",
|
||||
5: "e",
|
||||
},
|
||||
N: {
|
||||
1: "A",
|
||||
2: "B",
|
||||
3: "C",
|
||||
4: "D",
|
||||
5: "E",
|
||||
},
|
||||
} as Record<string, Record<number, string>>;
|
||||
|
||||
const decodePrefixMapNumber = Object.fromEntries(
|
||||
Object.entries(prefixMapNumber).flatMap(([prefix, map]) =>
|
||||
Object.entries(map).map(([len, char]) => [char, { prefix, len: parseInt(len) }])
|
||||
)
|
||||
);
|
||||
export function encodeAnyArray(obj: any[]): string {
|
||||
const tempArray = obj.map((v) => {
|
||||
if (v == null) return "n";
|
||||
if (v == false) return "f";
|
||||
if (v == true) return "t";
|
||||
if (v == undefined) return "u";
|
||||
if (typeof v == "number") {
|
||||
const b36 = v.toString(36);
|
||||
const strNum = v.toString();
|
||||
const expression = b36.length < strNum.length ? "N" : "n";
|
||||
const encodedStr = expression == "N" ? b36 : strNum;
|
||||
const len = encodedStr.length.toString(36);
|
||||
const lenLen = len.length;
|
||||
|
||||
const prefix2 = prefixMapNumber[expression][lenLen];
|
||||
return prefix2 + len + encodedStr;
|
||||
}
|
||||
const str = typeof v == "string" ? v : JSON.stringify(v);
|
||||
const prefix = typeof v == "string" ? "s" : "o";
|
||||
const length = str.length.toString(36);
|
||||
const lenLen = length.length;
|
||||
|
||||
const prefix2 = prefixMapObject[prefix][lenLen];
|
||||
return prefix2 + length + str;
|
||||
});
|
||||
const w = tempArray.join("");
|
||||
return w;
|
||||
}
|
||||
|
||||
const decodeMapConstant = {
|
||||
u: undefined,
|
||||
n: null,
|
||||
f: false,
|
||||
t: true,
|
||||
} as Record<string, any>;
|
||||
export function decodeAnyArray(str: string): any[] {
|
||||
const result = [];
|
||||
let i = 0;
|
||||
while (i < str.length) {
|
||||
const char = str[i];
|
||||
i++;
|
||||
if (char in decodeMapConstant) {
|
||||
result.push(decodeMapConstant[char]);
|
||||
continue;
|
||||
}
|
||||
if (char in decodePrefixMapNumber) {
|
||||
const { prefix, len } = decodePrefixMapNumber[char];
|
||||
const lenStr = str.substring(i, i + len);
|
||||
i += len;
|
||||
const radix = prefix == "N" ? 36 : 10;
|
||||
const lenNum = parseInt(lenStr, 36);
|
||||
const value = str.substring(i, i + lenNum);
|
||||
i += lenNum;
|
||||
result.push(parseInt(value, radix));
|
||||
continue;
|
||||
}
|
||||
const { prefix, len } = decodePrefixMapObject[char];
|
||||
const lenStr = str.substring(i, i + len);
|
||||
i += len;
|
||||
const lenNum = parseInt(lenStr, 36);
|
||||
const value = str.substring(i, i + lenNum);
|
||||
i += lenNum;
|
||||
if (prefix == "s") {
|
||||
result.push(value);
|
||||
} else {
|
||||
result.push(JSON.parse(value));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
40
src/deps.ts
40
src/deps.ts
@@ -1,13 +1,39 @@
|
||||
import { type FilePath } from "./lib/src/common/types.ts";
|
||||
|
||||
export {
|
||||
addIcon, App, debounce, Editor, FuzzySuggestModal, MarkdownRenderer, MarkdownView, Modal, Notice, Platform, Plugin, PluginSettingTab, requestUrl, sanitizeHTMLToDom, Setting, stringifyYaml, TAbstractFile, TextAreaComponent, TFile, TFolder,
|
||||
parseYaml, ItemView, WorkspaceLeaf
|
||||
addIcon,
|
||||
App,
|
||||
debounce,
|
||||
Editor,
|
||||
FuzzySuggestModal,
|
||||
MarkdownRenderer,
|
||||
MarkdownView,
|
||||
Modal,
|
||||
Notice,
|
||||
Platform,
|
||||
Plugin,
|
||||
PluginSettingTab,
|
||||
requestUrl,
|
||||
sanitizeHTMLToDom,
|
||||
Setting,
|
||||
stringifyYaml,
|
||||
TAbstractFile,
|
||||
TextAreaComponent,
|
||||
TFile,
|
||||
TFolder,
|
||||
parseYaml,
|
||||
ItemView,
|
||||
WorkspaceLeaf,
|
||||
} from "obsidian";
|
||||
export type { DataWriteOptions, PluginManifest, RequestUrlParam, RequestUrlResponse, MarkdownFileInfo, ListedFiles } from "obsidian";
|
||||
import {
|
||||
normalizePath as normalizePath_
|
||||
export type {
|
||||
DataWriteOptions,
|
||||
PluginManifest,
|
||||
RequestUrlParam,
|
||||
RequestUrlResponse,
|
||||
MarkdownFileInfo,
|
||||
ListedFiles,
|
||||
} from "obsidian";
|
||||
import { normalizePath as normalizePath_ } from "obsidian";
|
||||
const normalizePath = normalizePath_ as <T extends string | FilePath>(from: T) => T;
|
||||
export { normalizePath }
|
||||
export { type Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "diff-match-patch";
|
||||
export { normalizePath };
|
||||
export { type Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "diff-match-patch";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { ConfigSync, PluginDataExDisplayV2, type IPluginDataExDisplay } from "./CmdConfigSync.ts";
|
||||
import {
|
||||
ConfigSync,
|
||||
PluginDataExDisplayV2,
|
||||
type IPluginDataExDisplay,
|
||||
type PluginDataExFile,
|
||||
} from "./CmdConfigSync.ts";
|
||||
import { Logger } from "../../lib/src/common/logger";
|
||||
import { type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../../lib/src/common/types";
|
||||
import { getDocData, timeDeltaToHumanReadable, unique } from "../../lib/src/common/utils";
|
||||
@@ -15,7 +20,11 @@
|
||||
export let applyAllPluse = 0;
|
||||
|
||||
export let applyData: (data: IPluginDataExDisplay) => Promise<boolean>;
|
||||
export let compareData: (dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach?: boolean) => Promise<boolean>;
|
||||
export let compareData: (
|
||||
dataA: IPluginDataExDisplay,
|
||||
dataB: IPluginDataExDisplay,
|
||||
compareEach?: boolean
|
||||
) => Promise<boolean>;
|
||||
export let deleteData: (data: IPluginDataExDisplay) => Promise<boolean>;
|
||||
export let hidden: boolean;
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
@@ -151,7 +160,11 @@
|
||||
canCompare = result.canCompare;
|
||||
pickToCompare = false;
|
||||
if (canCompare) {
|
||||
if (local?.files.length == remote?.files.length && local?.files.length == 1 && local?.files[0].filename == remote?.files[0].filename) {
|
||||
if (
|
||||
local?.files.length == remote?.files.length &&
|
||||
local?.files.length == 1 &&
|
||||
local?.files[0].filename == remote?.files[0].filename
|
||||
) {
|
||||
pickToCompare = false;
|
||||
} else {
|
||||
pickToCompare = true;
|
||||
@@ -250,7 +263,11 @@
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
await compareItems(local, selectedItem);
|
||||
}
|
||||
async function compareItems(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined, filename?: string) {
|
||||
async function compareItems(
|
||||
local: IPluginDataExDisplay | undefined,
|
||||
remote: IPluginDataExDisplay | undefined,
|
||||
filename?: string
|
||||
) {
|
||||
if (local && remote) {
|
||||
if (!filename) {
|
||||
if (await compareData(local, remote)) {
|
||||
@@ -258,8 +275,10 @@
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const localCopy = local instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(local) : { ...local };
|
||||
const remoteCopy = remote instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(remote) : { ...remote };
|
||||
const localCopy =
|
||||
local instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(local) : { ...local };
|
||||
const remoteCopy =
|
||||
remote instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(remote) : { ...remote };
|
||||
localCopy.files = localCopy.files.filter((e) => e.filename == filename);
|
||||
remoteCopy.files = remoteCopy.files.filter((e) => e.filename == filename);
|
||||
if (await compareData(localCopy, remoteCopy, true)) {
|
||||
@@ -287,9 +306,17 @@
|
||||
menu.addItem((item) => item.setTitle("Compare file").setIsLabel(true));
|
||||
menu.addSeparator();
|
||||
const files = unique(local.files.map((e) => e.filename).concat(selectedItem.files.map((e) => e.filename)));
|
||||
const convDate = (dt: PluginDataExFile | undefined) => {
|
||||
if (!dt) return "(Missing)";
|
||||
const d = new Date(dt.mtime);
|
||||
return d.toLocaleString();
|
||||
};
|
||||
for (const filename of files) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(filename).onClick((e) => compareItems(local, selectedItem, filename));
|
||||
const localFile = local.files.find((e) => e.filename == filename);
|
||||
const remoteFile = selectedItem.files.find((e) => e.filename == filename);
|
||||
const title = `${filename} (${convDate(localFile)} <--> ${convDate(remoteFile)})`;
|
||||
item.setTitle(title).onClick((e) => compareItems(local, selectedItem, filename));
|
||||
});
|
||||
}
|
||||
menu.showAtMouseEvent(evt);
|
||||
@@ -321,7 +348,7 @@
|
||||
</script>
|
||||
|
||||
{#if terms.length > 0}
|
||||
<span class="spacer" />
|
||||
<span class="spacer"></span>
|
||||
{#if !hidden}
|
||||
<span class="chip-wrap">
|
||||
<span class="chip modified">{freshness}</span>
|
||||
@@ -343,12 +370,15 @@
|
||||
<button on:click={compareSelected}>⮂</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button disabled />
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button disabled></button>
|
||||
{/if}
|
||||
<button on:click={applySelected}>✓</button>
|
||||
{:else}
|
||||
<button disabled />
|
||||
<button disabled />
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button disabled></button>
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button disabled></button>
|
||||
{/if}
|
||||
{#if isMaintenanceMode}
|
||||
{#if selected != ""}
|
||||
@@ -359,10 +389,12 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="spacer" />
|
||||
<span class="spacer"></span>
|
||||
<span class="message even">All the same or non-existent</span>
|
||||
<button disabled />
|
||||
<button disabled />
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button disabled></button>
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button disabled></button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mount, unmount } from "svelte";
|
||||
import { App, Modal } from "../../deps.ts";
|
||||
import ObsidianLiveSyncPlugin from "../../main.ts";
|
||||
import PluginPane from "./PluginPane.svelte";
|
||||
export class PluginDialogModal extends Modal {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
component: PluginPane | undefined;
|
||||
component: ReturnType<typeof mount> | undefined;
|
||||
isOpened() {
|
||||
return this.component != undefined;
|
||||
}
|
||||
@@ -18,18 +19,19 @@ export class PluginDialogModal extends Modal {
|
||||
this.contentEl.style.overflow = "auto";
|
||||
this.contentEl.style.display = "flex";
|
||||
this.contentEl.style.flexDirection = "column";
|
||||
this.titleEl.setText("Customization Sync (Beta3)")
|
||||
this.titleEl.setText("Customization Sync (Beta3)");
|
||||
if (!this.component) {
|
||||
this.component = new PluginPane({
|
||||
target: contentEl, props: { plugin: this.plugin },
|
||||
this.component = mount(PluginPane, {
|
||||
target: contentEl,
|
||||
props: { plugin: this.plugin },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.component) {
|
||||
this.component.$destroy();
|
||||
void unmount(this.component);
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import ObsidianLiveSyncPlugin from "../../main";
|
||||
import { ConfigSync, type IPluginDataExDisplay, pluginIsEnumerating, pluginList, pluginManifestStore, pluginV2Progress } from "./CmdConfigSync.ts";
|
||||
import {
|
||||
ConfigSync,
|
||||
type IPluginDataExDisplay,
|
||||
pluginIsEnumerating,
|
||||
pluginList,
|
||||
pluginManifestStore,
|
||||
pluginV2Progress,
|
||||
} from "./CmdConfigSync.ts";
|
||||
import PluginCombo from "./PluginCombo.svelte";
|
||||
import { Menu, type PluginManifest } from "obsidian";
|
||||
import { unique } from "../../lib/src/common/utils";
|
||||
import { MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, type SYNC_MODE, type PluginSyncSettingEntry, MODE_SHINY } from "../../lib/src/common/types";
|
||||
import {
|
||||
MODE_SELECTIVE,
|
||||
MODE_AUTOMATIC,
|
||||
MODE_PAUSED,
|
||||
type SYNC_MODE,
|
||||
MODE_SHINY,
|
||||
} from "../../lib/src/common/types";
|
||||
import { normalizePath } from "../../deps";
|
||||
import { HiddenFileSync } from "../HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
|
||||
$: hideNotApplicable = false;
|
||||
$: thisTerm = plugin.deviceAndVaultName;
|
||||
$: thisTerm = plugin.$$getDeviceAndVaultName();
|
||||
|
||||
const addOn = plugin.getAddOn(ConfigSync.name) as ConfigSync;
|
||||
if (!addOn) {
|
||||
const msg = "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.";
|
||||
const msg =
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.";
|
||||
Logger(msg, LOG_LEVEL_NOTICE);
|
||||
throw new Error(msg);
|
||||
}
|
||||
const addOnHiddenFileSync = plugin.getAddOn(HiddenFileSync.name) as HiddenFileSync;
|
||||
if (!addOnHiddenFileSync) {
|
||||
const msg = "AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.";
|
||||
const msg =
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.";
|
||||
Logger(msg, LOG_LEVEL_NOTICE);
|
||||
throw new Error(msg);
|
||||
}
|
||||
@@ -99,7 +114,11 @@
|
||||
async function applyData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.applyData(data);
|
||||
}
|
||||
async function compareData(docA: IPluginDataExDisplay, docB: IPluginDataExDisplay, compareEach = false): Promise<boolean> {
|
||||
async function compareData(
|
||||
docA: IPluginDataExDisplay,
|
||||
docB: IPluginDataExDisplay,
|
||||
compareEach = false
|
||||
): Promise<boolean> {
|
||||
return await addOn.compareUsingDisplayData(docA, docB, compareEach);
|
||||
}
|
||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
@@ -130,7 +149,7 @@
|
||||
setMode(key, MODE_AUTOMATIC);
|
||||
const configDir = normalizePath(plugin.app.vault.configDir);
|
||||
const files = (plugin.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
|
||||
addOnHiddenFileSync.syncInternalFilesAndDatabase(direction, true, false, files);
|
||||
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
}
|
||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||
const menu = new Menu();
|
||||
@@ -199,7 +218,7 @@
|
||||
.filter((e) => `${e.category}/${e.name}` == key)
|
||||
.map((e) => e.files)
|
||||
.flat()
|
||||
.map((e) => e.filename),
|
||||
.map((e) => e.filename)
|
||||
);
|
||||
if (mode == MODE_SELECTIVE) {
|
||||
automaticList.delete(key);
|
||||
@@ -249,7 +268,15 @@
|
||||
.map((e) => ({ category: e[0], name: e[1], displayName: e[1] })),
|
||||
]
|
||||
.sort((a, b) => (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name))
|
||||
.reduce((p, c) => ({ ...p, [c.category]: unique(c.category in p ? [...p[c.category], c.displayName ?? c.name] : [c.displayName ?? c.name]) }), {} as Record<string, string[]>);
|
||||
.reduce(
|
||||
(p, c) => ({
|
||||
...p,
|
||||
[c.category]: unique(
|
||||
c.category in p ? [...p[c.category], c.displayName ?? c.name] : [c.displayName ?? c.name]
|
||||
),
|
||||
}),
|
||||
{} as Record<string, string[]>
|
||||
);
|
||||
}
|
||||
$: {
|
||||
displayKeys = computeDisplayKeys(list);
|
||||
@@ -337,7 +364,12 @@
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if mode == MODE_SELECTIVE || mode == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={mode == MODE_SHINY} list={list.filter((e) => e.category == key && e.name == name)} hidden={false} />
|
||||
<PluginCombo
|
||||
{...options}
|
||||
isFlagged={mode == MODE_SHINY}
|
||||
list={list.filter((e) => e.category == key && e.name == name)}
|
||||
hidden={false}
|
||||
/>
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[mode]}</div>
|
||||
{/if}
|
||||
@@ -359,7 +391,10 @@
|
||||
{@const modeEtc = automaticListDisp.get(bindKeyETC) ?? MODE_SELECTIVE}
|
||||
<div class="labelrow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="title">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ALL}/${name}`, bindKeyAll)}>
|
||||
<button
|
||||
class="status"
|
||||
on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ALL}/${name}`, bindKeyAll)}
|
||||
>
|
||||
{getIcon(modeAll)}
|
||||
</button>
|
||||
<span class="name">{nameMap.get(`plugins/${name}`) || name}</span>
|
||||
@@ -373,14 +408,22 @@
|
||||
{#if modeAll == MODE_SELECTIVE || modeAll == MODE_SHINY}
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_MAIN}/${name}/MAIN`, bindKeyMain)}>
|
||||
<button
|
||||
class="status"
|
||||
on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_MAIN}/${name}/MAIN`, bindKeyMain)}
|
||||
>
|
||||
{getIcon(modeMain)}
|
||||
</button>
|
||||
<span class="name">MAIN</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeMain == MODE_SELECTIVE || modeMain == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeMain == MODE_SHINY} list={filterList(listX, ["PLUGIN_MAIN"])} hidden={false} />
|
||||
<PluginCombo
|
||||
{...options}
|
||||
isFlagged={modeMain == MODE_SHINY}
|
||||
list={filterList(listX, ["PLUGIN_MAIN"])}
|
||||
hidden={false}
|
||||
/>
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[modeMain]}</div>
|
||||
{/if}
|
||||
@@ -388,14 +431,22 @@
|
||||
</div>
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_DATA}/${name}`, bindKeyData)}>
|
||||
<button
|
||||
class="status"
|
||||
on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_DATA}/${name}`, bindKeyData)}
|
||||
>
|
||||
{getIcon(modeData)}
|
||||
</button>
|
||||
<span class="name">DATA</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeData == MODE_SELECTIVE || modeData == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeData == MODE_SHINY} list={filterList(listX, ["PLUGIN_DATA"])} hidden={false} />
|
||||
<PluginCombo
|
||||
{...options}
|
||||
isFlagged={modeData == MODE_SHINY}
|
||||
list={filterList(listX, ["PLUGIN_DATA"])}
|
||||
hidden={false}
|
||||
/>
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[modeData]}</div>
|
||||
{/if}
|
||||
@@ -404,14 +455,22 @@
|
||||
{#if useSyncPluginEtc}
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ETC}/${name}`, bindKeyETC)}>
|
||||
<button
|
||||
class="status"
|
||||
on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ETC}/${name}`, bindKeyETC)}
|
||||
>
|
||||
{getIcon(modeEtc)}
|
||||
</button>
|
||||
<span class="name">Other files</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeEtc == MODE_SELECTIVE || modeEtc == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeEtc == MODE_SHINY} list={filterList(listX, ["PLUGIN_ETC"])} hidden={false} />
|
||||
<PluginCombo
|
||||
{...options}
|
||||
isFlagged={modeEtc == MODE_SHINY}
|
||||
list={filterList(listX, ["PLUGIN_ETC"])}
|
||||
hidden={false}
|
||||
/>
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[modeEtc]}</div>
|
||||
{/if}
|
||||
@@ -483,10 +542,12 @@
|
||||
padding-right: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filerow.hideeven:has(.even),
|
||||
.labelrow.hideeven:has(.even) {
|
||||
|
||||
.filerow.hideeven:has(:global(.even)),
|
||||
.labelrow.hideeven:has(:global(.even)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.noterow {
|
||||
min-height: 2em;
|
||||
display: flex;
|
||||
|
||||
@@ -2,13 +2,14 @@ import { App, Modal } from "../../deps.ts";
|
||||
import { type FilePath, type LoadedEntry } from "../../lib/src/common/types.ts";
|
||||
import JsonResolvePane from "./JsonResolvePane.svelte";
|
||||
import { waitForSignal } from "../../lib/src/common/utils.ts";
|
||||
import { mount, unmount } from "svelte";
|
||||
|
||||
export class JsonResolveModal extends Modal {
|
||||
// result: Array<[number, string]>;
|
||||
filename: FilePath;
|
||||
callback?: (keepRev?: string, mergedStr?: string) => Promise<void>;
|
||||
docs: LoadedEntry[];
|
||||
component?: JsonResolvePane;
|
||||
component?: ReturnType<typeof mount>;
|
||||
nameA: string;
|
||||
nameB: string;
|
||||
defaultSelect: string;
|
||||
@@ -16,10 +17,18 @@ export class JsonResolveModal extends Modal {
|
||||
hideLocal: boolean;
|
||||
title: string = "Conflicted Setting";
|
||||
|
||||
constructor(app: App, filename: FilePath,
|
||||
docs: LoadedEntry[], callback: (keepRev?: string, mergedStr?: string) => Promise<void>,
|
||||
nameA?: string, nameB?: string, defaultSelect?: string,
|
||||
keepOrder?: boolean, hideLocal?: boolean, title: string = "Conflicted Setting") {
|
||||
constructor(
|
||||
app: App,
|
||||
filename: FilePath,
|
||||
docs: LoadedEntry[],
|
||||
callback: (keepRev?: string, mergedStr?: string) => Promise<void>,
|
||||
nameA?: string,
|
||||
nameB?: string,
|
||||
defaultSelect?: string,
|
||||
keepOrder?: boolean,
|
||||
hideLocal?: boolean,
|
||||
title: string = "Conflicted Setting"
|
||||
) {
|
||||
super(app);
|
||||
this.callback = callback;
|
||||
this.filename = filename;
|
||||
@@ -32,9 +41,12 @@ export class JsonResolveModal extends Modal {
|
||||
this.hideLocal = hideLocal ?? false;
|
||||
void waitForSignal(`cancel-internal-conflict:${filename}`).then(() => this.close());
|
||||
}
|
||||
|
||||
async UICallback(keepRev?: string, mergedStr?: string) {
|
||||
if (this.callback) {
|
||||
await this.callback(keepRev, mergedStr);
|
||||
}
|
||||
this.close();
|
||||
await this.callback?.(keepRev, mergedStr);
|
||||
this.callback = undefined;
|
||||
}
|
||||
|
||||
@@ -44,7 +56,7 @@ export class JsonResolveModal extends Modal {
|
||||
contentEl.empty();
|
||||
|
||||
if (this.component == undefined) {
|
||||
this.component = new JsonResolvePane({
|
||||
this.component = mount(JsonResolvePane, {
|
||||
target: contentEl,
|
||||
props: {
|
||||
docs: this.docs,
|
||||
@@ -54,14 +66,14 @@ export class JsonResolveModal extends Modal {
|
||||
defaultSelect: this.defaultSelect,
|
||||
keepOrder: this.keepOrder,
|
||||
hideLocal: this.hideLocal,
|
||||
callback: (keepRev: string | undefined, mergedStr: string | undefined) => this.UICallback(keepRev, mergedStr),
|
||||
callback: (keepRev: string | undefined, mergedStr: string | undefined) =>
|
||||
this.UICallback(keepRev, mergedStr),
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
@@ -70,7 +82,7 @@ export class JsonResolveModal extends Modal {
|
||||
void this.callback(undefined);
|
||||
}
|
||||
if (this.component != undefined) {
|
||||
this.component.$destroy();
|
||||
void unmount(this.component);
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,29 +2,64 @@
|
||||
import { type Diff, DIFF_DELETE, DIFF_INSERT, diff_match_patch } from "../../deps.ts";
|
||||
import type { FilePath, LoadedEntry } from "../../lib/src/common/types.ts";
|
||||
import { decodeBinary, readString } from "../../lib/src/string_and_binary/convert.ts";
|
||||
import { getDocData, mergeObject } from "../../lib/src/common/utils.ts";
|
||||
import { getDocData, isObjectDifferent, mergeObject } from "../../lib/src/common/utils.ts";
|
||||
|
||||
export let docs: LoadedEntry[] = [];
|
||||
export let callback: (keepRev?: string, mergedStr?: string) => Promise<void> = async (_, __) => {
|
||||
Promise.resolve();
|
||||
};
|
||||
export let filename: FilePath = "" as FilePath;
|
||||
export let nameA: string = "A";
|
||||
export let nameB: string = "B";
|
||||
export let defaultSelect: string = "";
|
||||
export let keepOrder = false;
|
||||
export let hideLocal: boolean = false;
|
||||
let docA: LoadedEntry;
|
||||
let docB: LoadedEntry;
|
||||
let docAContent = "";
|
||||
let docBContent = "";
|
||||
let objA: any = {};
|
||||
let objB: any = {};
|
||||
let objAB: any = {};
|
||||
let objBA: any = {};
|
||||
let diffs: Diff[];
|
||||
interface Props {
|
||||
docs?: LoadedEntry[];
|
||||
callback?: (keepRev?: string, mergedStr?: string) => Promise<void>;
|
||||
filename?: FilePath;
|
||||
nameA?: string;
|
||||
nameB?: string;
|
||||
defaultSelect?: string;
|
||||
keepOrder?: boolean;
|
||||
hideLocal?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
docs = $bindable([]),
|
||||
callback = $bindable((async (_, __) => {
|
||||
Promise.resolve();
|
||||
}) as (keepRev?: string, mergedStr?: string) => Promise<void>),
|
||||
filename = $bindable("" as FilePath),
|
||||
nameA = $bindable("A"),
|
||||
nameB = $bindable("B"),
|
||||
defaultSelect = $bindable("" as string),
|
||||
keepOrder = $bindable(false),
|
||||
hideLocal = $bindable(false),
|
||||
}: Props = $props();
|
||||
type JSONData = Record<string | number | symbol, any> | [any];
|
||||
|
||||
const docsArray = $derived.by(() => {
|
||||
if (docs && docs.length >= 1) {
|
||||
if (keepOrder || docs[0].mtime < docs[1].mtime) {
|
||||
return { a: docs[0], b: docs[1] } as const;
|
||||
} else {
|
||||
return { a: docs[1], b: docs[0] } as const;
|
||||
}
|
||||
}
|
||||
return { a: false, b: false } as const;
|
||||
});
|
||||
const docA = $derived(docsArray.a);
|
||||
const docB = $derived(docsArray.b);
|
||||
const docAContent = $derived(docA && docToString(docA));
|
||||
const docBContent = $derived(docB && docToString(docB));
|
||||
|
||||
function parseJson(json: string | false) {
|
||||
if (json === false) return false;
|
||||
try {
|
||||
return JSON.parse(json) as JSONData;
|
||||
} catch (ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const objA = $derived(parseJson(docAContent) || {});
|
||||
const objB = $derived(parseJson(docBContent) || {});
|
||||
const objAB = $derived(mergeObject(objA, objB));
|
||||
const objBAw = $derived(mergeObject(objB, objA));
|
||||
const objBA = $derived(isObjectDifferent(objBAw, objAB) ? objBAw : false);
|
||||
let diffs: Diff[] = $derived.by(() => (objA && selectedObj ? getJsonDiff(objA, selectedObj) : []));
|
||||
type SelectModes = "" | "A" | "B" | "AB" | "BA";
|
||||
let mode: SelectModes = defaultSelect as SelectModes;
|
||||
let mode: SelectModes = $state(defaultSelect as SelectModes);
|
||||
|
||||
function docToString(doc: LoadedEntry) {
|
||||
return doc.datatype == "plain" ? getDocData(doc.data) : readString(new Uint8Array(decodeBinary(doc.data)));
|
||||
@@ -45,6 +80,7 @@
|
||||
return getDiff(JSON.stringify(a, null, 2), JSON.stringify(b, null, 2));
|
||||
}
|
||||
function apply() {
|
||||
if (!docA || !docB) return;
|
||||
if (docA._id == docB._id) {
|
||||
if (mode == "A") return callback(docA._rev!, undefined);
|
||||
if (mode == "B") return callback(docB._rev!, undefined);
|
||||
@@ -59,50 +95,23 @@
|
||||
function cancel() {
|
||||
callback(undefined, undefined);
|
||||
}
|
||||
$: {
|
||||
if (docs && docs.length >= 1) {
|
||||
if (keepOrder || docs[0].mtime < docs[1].mtime) {
|
||||
docA = docs[0];
|
||||
docB = docs[1];
|
||||
} else {
|
||||
docA = docs[1];
|
||||
docB = docs[0];
|
||||
}
|
||||
docAContent = docToString(docA);
|
||||
docBContent = docToString(docB);
|
||||
const mergedObjs = $derived.by(
|
||||
() =>
|
||||
({
|
||||
"": false,
|
||||
A: objA,
|
||||
B: objB,
|
||||
AB: objAB,
|
||||
BA: objBA,
|
||||
}) as Record<SelectModes, JSONData | false>
|
||||
);
|
||||
|
||||
try {
|
||||
objA = false;
|
||||
objB = false;
|
||||
objA = JSON.parse(docAContent);
|
||||
objB = JSON.parse(docBContent);
|
||||
objAB = mergeObject(objA, objB);
|
||||
objBA = mergeObject(objB, objA);
|
||||
if (JSON.stringify(objAB) == JSON.stringify(objBA)) {
|
||||
objBA = false;
|
||||
}
|
||||
} catch (ex) {
|
||||
objBA = false;
|
||||
objAB = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
$: mergedObjs = {
|
||||
"": false,
|
||||
A: objA,
|
||||
B: objB,
|
||||
AB: objAB,
|
||||
BA: objBA,
|
||||
};
|
||||
let selectedObj = $derived(mode in mergedObjs ? mergedObjs[mode] : {});
|
||||
|
||||
$: selectedObj = mode in mergedObjs ? mergedObjs[mode] : {};
|
||||
$: {
|
||||
diffs = getJsonDiff(objA, selectedObj);
|
||||
}
|
||||
let modesSrc = $state([] as ["" | "A" | "B" | "AB" | "BA", string][]);
|
||||
|
||||
let modes = [] as ["" | "A" | "B" | "AB" | "BA", string][];
|
||||
$: {
|
||||
let newModes = [] as typeof modes;
|
||||
const modes = $derived.by(() => {
|
||||
let newModes = [] as typeof modesSrc;
|
||||
|
||||
if (!hideLocal) {
|
||||
newModes.push(["", "Not now"]);
|
||||
@@ -111,15 +120,15 @@
|
||||
newModes.push(["B", nameB || "B"]);
|
||||
newModes.push(["AB", `${nameA || "A"} + ${nameB || "B"}`]);
|
||||
newModes.push(["BA", `${nameB || "B"} + ${nameA || "A"}`]);
|
||||
modes = newModes;
|
||||
}
|
||||
return newModes;
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2>{filename}</h2>
|
||||
{#if !docA || !docB}
|
||||
<div class="message">Just for a minute, please!</div>
|
||||
<div class="buttons">
|
||||
<button on:click={apply}>Dismiss</button>
|
||||
<button onclick={apply}>Dismiss</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="options">
|
||||
@@ -136,7 +145,9 @@
|
||||
{#if selectedObj != false}
|
||||
<div class="op-scrollable json-source">
|
||||
{#each diffs as diff}
|
||||
<span class={diff[0] == DIFF_DELETE ? "deleted" : diff[0] == DIFF_INSERT ? "added" : "normal"}>{diff[1]}</span>
|
||||
<span class={diff[0] == DIFF_DELETE ? "deleted" : diff[0] == DIFF_INSERT ? "added" : "normal"}
|
||||
>{diff[1]}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -145,38 +156,40 @@
|
||||
|
||||
<div class="infos">
|
||||
<table>
|
||||
<tr>
|
||||
<th>{nameA}</th>
|
||||
<td
|
||||
>{#if docA._id == docB._id}
|
||||
Rev:{revStringToRevNumber(docA._rev)}
|
||||
{/if}
|
||||
{new Date(docA.mtime).toLocaleString()}</td
|
||||
>
|
||||
<td>
|
||||
{docAContent.length} letters
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{nameB}</th>
|
||||
<td
|
||||
>{#if docA._id == docB._id}
|
||||
Rev:{revStringToRevNumber(docB._rev)}
|
||||
{/if}
|
||||
{new Date(docB.mtime).toLocaleString()}</td
|
||||
>
|
||||
<td>
|
||||
{docBContent.length} letters
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{nameA}</th>
|
||||
<td
|
||||
>{#if docA._id == docB._id}
|
||||
Rev:{revStringToRevNumber(docA._rev)}
|
||||
{/if}
|
||||
{new Date(docA.mtime).toLocaleString()}</td
|
||||
>
|
||||
<td>
|
||||
{docAContent && docAContent.length} letters
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{nameB}</th>
|
||||
<td
|
||||
>{#if docA._id == docB._id}
|
||||
Rev:{revStringToRevNumber(docB._rev)}
|
||||
{/if}
|
||||
{new Date(docB.mtime).toLocaleString()}</td
|
||||
>
|
||||
<td>
|
||||
{docBContent && docBContent.length} letters
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
{#if hideLocal}
|
||||
<button on:click={cancel}>Cancel</button>
|
||||
<button onclick={cancel}>Cancel</button>
|
||||
{/if}
|
||||
<button on:click={apply}>Apply</button>
|
||||
<button onclick={apply}>Apply</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -203,6 +216,7 @@
|
||||
overflow-y: scroll;
|
||||
max-height: 60vh;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
.json-source {
|
||||
white-space: pre;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,19 @@
|
||||
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { getPath } from "../common/utils.ts";
|
||||
import { type AnyEntry, type DocumentID, type EntryHasPath, type FilePath, type FilePathWithPrefix } from "../lib/src/common/types.ts";
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type AnyEntry,
|
||||
type DocumentID,
|
||||
type EntryHasPath,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type LOG_LEVEL,
|
||||
} from "../lib/src/common/types.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
import { MARK_DONE } from "../modules/features/ModuleLog.ts";
|
||||
|
||||
|
||||
let noticeIndex = 0;
|
||||
export abstract class LiveSyncCommands {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
get app() {
|
||||
@@ -32,12 +43,50 @@ export abstract class LiveSyncCommands {
|
||||
abstract onload(): void | Promise<void>;
|
||||
|
||||
_isMainReady() {
|
||||
return this.plugin._isMainReady();
|
||||
return this.plugin.$$isReady();
|
||||
}
|
||||
_isMainSuspended() {
|
||||
return this.plugin._isMainSuspended();
|
||||
return this.plugin.$$isSuspended();
|
||||
}
|
||||
_isDatabaseReady() {
|
||||
return this.plugin._isDatabaseReady();
|
||||
return this.plugin.$$isDatabaseReady();
|
||||
}
|
||||
|
||||
_log = (msg: any, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string) => {
|
||||
if (typeof msg === "string" && level !== LOG_LEVEL_NOTICE) {
|
||||
msg = `[${this.constructor.name}]\u{200A} ${msg}`;
|
||||
}
|
||||
// console.log(msg);
|
||||
Logger(msg, level, key);
|
||||
};
|
||||
|
||||
_verbose = (msg: any, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_VERBOSE, key);
|
||||
};
|
||||
|
||||
_info = (msg: any, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_INFO, key);
|
||||
};
|
||||
|
||||
_notice = (msg: any, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_NOTICE, key);
|
||||
};
|
||||
_progress = (prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE) => {
|
||||
const key = `keepalive-progress-${noticeIndex++}`;
|
||||
return {
|
||||
log: (msg: any) => {
|
||||
this._log(prefix + msg, level, key);
|
||||
},
|
||||
once: (msg: any) => {
|
||||
this._log(prefix + msg, level);
|
||||
},
|
||||
done: (msg: string = "Done") => {
|
||||
this._log(prefix + msg + MARK_DONE, level, key);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
_debug = (msg: any, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_VERBOSE, key);
|
||||
};
|
||||
}
|
||||
|
||||
265
src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts
Normal file
265
src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { sizeToHumanReadable } from "octagonal-wheels/number";
|
||||
import { LOG_LEVEL_NOTICE, type MetaEntry } from "../../lib/src/common/types";
|
||||
import { getNoFromRev } from "../../lib/src/pouchdb/LiveSyncLocalDB";
|
||||
import type { IObsidianModule } from "../../modules/AbstractObsidianModule";
|
||||
import { LiveSyncCommands } from "../LiveSyncCommands";
|
||||
|
||||
export class LocalDatabaseMaintenance extends LiveSyncCommands implements IObsidianModule {
|
||||
$everyOnload(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
onunload(): void {
|
||||
// NO OP.
|
||||
}
|
||||
onload(): void | Promise<void> {
|
||||
// NO OP.
|
||||
}
|
||||
async allChunks(includeDeleted: boolean = false) {
|
||||
const p = this._progress("", LOG_LEVEL_NOTICE);
|
||||
p.log("Retrieving chunks informations..");
|
||||
try {
|
||||
const ret = await this.localDatabase.allChunks(includeDeleted);
|
||||
return ret;
|
||||
} finally {
|
||||
p.done();
|
||||
}
|
||||
}
|
||||
get database() {
|
||||
return this.localDatabase.localDatabase;
|
||||
}
|
||||
clearHash() {
|
||||
this.localDatabase.hashCaches.clear();
|
||||
}
|
||||
|
||||
async confirm(title: string, message: string, affirmative = "Yes", negative = "No") {
|
||||
return (
|
||||
(await this.plugin.confirm.askSelectStringDialogue(message, [affirmative, negative], {
|
||||
title,
|
||||
defaultAction: affirmative,
|
||||
})) === affirmative
|
||||
);
|
||||
}
|
||||
isAvailable() {
|
||||
if (!this.settings.doNotUseFixedRevisionForChunks) {
|
||||
this._notice("Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.");
|
||||
return false;
|
||||
}
|
||||
if (this.settings.readChunksOnline) {
|
||||
this._notice("Please disable 'Read chunks online' in settings to use Garbage Collection.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Resurrect deleted chunks that are still used in the database.
|
||||
*/
|
||||
async resurrectChunks() {
|
||||
if (!this.isAvailable()) return;
|
||||
const { used, existing } = await this.allChunks(true);
|
||||
const excessiveDeletions = [...existing]
|
||||
.filter(([key, e]) => e._deleted)
|
||||
.filter(([key, e]) => used.has(e._id))
|
||||
.map(([key, e]) => e);
|
||||
const completelyLostChunks = [] as string[];
|
||||
// Data lost chunks : chunks that are deleted and data is purged.
|
||||
const dataLostChunks = [...existing]
|
||||
.filter(([key, e]) => e._deleted && e.data === "")
|
||||
.map(([key, e]) => e)
|
||||
.filter((e) => used.has(e._id));
|
||||
for (const e of dataLostChunks) {
|
||||
// Retrieve the data from the previous revision.
|
||||
const doc = await this.database.get(e._id, { rev: e._rev, revs: true, revs_info: true, conflicts: true });
|
||||
const history = doc._revs_info || [];
|
||||
// Chunks are immutable. So, we can resurrect the chunk by copying the data from any of previous revisions.
|
||||
let resurrected = null as null | string;
|
||||
const availableRevs = history
|
||||
.filter((e) => e.status == "available")
|
||||
.map((e) => e.rev)
|
||||
.sort((a, b) => getNoFromRev(a) - getNoFromRev(b));
|
||||
for (const rev of availableRevs) {
|
||||
const revDoc = await this.database.get(e._id, { rev: rev });
|
||||
if (revDoc.type == "leaf" && revDoc.data !== "") {
|
||||
// Found the data.
|
||||
resurrected = revDoc.data;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If the data is not found, we cannot resurrect the chunk, add it to the excessiveDeletions.
|
||||
if (resurrected !== null) {
|
||||
excessiveDeletions.push({ ...e, data: resurrected, _deleted: false });
|
||||
} else {
|
||||
completelyLostChunks.push(e._id);
|
||||
}
|
||||
}
|
||||
// Chunks to be resurrected.
|
||||
const resurrectChunks = excessiveDeletions.filter((e) => e.data !== "").map((e) => ({ ...e, _deleted: false }));
|
||||
|
||||
if (resurrectChunks.length == 0) {
|
||||
this._notice("No chunks are found to be resurrected.");
|
||||
return;
|
||||
}
|
||||
const message = `We have following chunks that are deleted but still used in the database.
|
||||
|
||||
- Completely lost chunks: ${completelyLostChunks.length}
|
||||
- Resurrectable chunks: ${resurrectChunks.length}
|
||||
|
||||
Do you want to resurrect these chunks?`;
|
||||
if (await this.confirm("Resurrect Chunks", message, "Resurrect", "Cancel")) {
|
||||
const result = await this.database.bulkDocs(resurrectChunks);
|
||||
this.clearHash();
|
||||
const resurrectedChunks = result.filter((e) => "ok" in e).map((e) => e.id);
|
||||
this._notice(`Resurrected chunks: ${resurrectedChunks.length} / ${resurrectChunks.length}`);
|
||||
} else {
|
||||
this._notice("Resurrect operation is cancelled.");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Commit deletion of files that are marked as deleted.
|
||||
* This method makes the deletion permanent, and the files will not be recovered.
|
||||
* After this, chunks that are used in the deleted files become ready for compaction.
|
||||
*/
|
||||
async commitFileDeletion() {
|
||||
if (!this.isAvailable()) return;
|
||||
const p = this._progress("", LOG_LEVEL_NOTICE);
|
||||
p.log("Searching for deleted files..");
|
||||
const docs = await this.database.allDocs<MetaEntry>({ include_docs: true });
|
||||
const deletedDocs = docs.rows.filter(
|
||||
(e) => (e.doc?.type == "newnote" || e.doc?.type == "plain") && e.doc?.deleted
|
||||
);
|
||||
if (deletedDocs.length == 0) {
|
||||
p.done("No deleted files found.");
|
||||
return;
|
||||
}
|
||||
p.log(`Found ${deletedDocs.length} deleted files.`);
|
||||
|
||||
const message = `We have following files that are marked as deleted.
|
||||
|
||||
- Deleted files: ${deletedDocs.length}
|
||||
|
||||
Are you sure to delete these files permanently?
|
||||
|
||||
Note: **Make sure to synchronise all devices before deletion.**
|
||||
|
||||
> [!Note]
|
||||
> This operation affects the database permanently. Deleted files will not be recovered after this operation.
|
||||
> And, the chunks that are used in the deleted files will be ready for compaction.`;
|
||||
|
||||
const deletingDocs = deletedDocs.map((e) => ({ ...e.doc, _deleted: true }) as MetaEntry);
|
||||
|
||||
if (await this.confirm("Delete Files", message, "Delete", "Cancel")) {
|
||||
const result = await this.database.bulkDocs(deletingDocs);
|
||||
this.clearHash();
|
||||
p.done(`Deleted ${result.filter((e) => "ok" in e).length} / ${deletedDocs.length} files.`);
|
||||
} else {
|
||||
p.done("Deletion operation is cancelled.");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Commit deletion of chunks that are not used in the database.
|
||||
* This method makes the deletion permanent, and the chunks will not be recovered if the database run compaction.
|
||||
* After this, the database can shrink the database size by compaction.
|
||||
* It is recommended to compact the database after this operation (History should be kept once before compaction).
|
||||
*/
|
||||
async commitChunkDeletion() {
|
||||
if (!this.isAvailable()) return;
|
||||
const { existing } = await this.allChunks(true);
|
||||
const deletedChunks = [...existing].filter(([key, e]) => e._deleted && e.data !== "").map(([key, e]) => e);
|
||||
const deletedNotVacantChunks = deletedChunks.map((e) => ({ ...e, data: "", _deleted: true }));
|
||||
const size = deletedChunks.reduce((acc, e) => acc + e.data.length, 0);
|
||||
const humanSize = sizeToHumanReadable(size);
|
||||
const message = `We have following chunks that are marked as deleted.
|
||||
|
||||
- Deleted chunks: ${deletedNotVacantChunks.length} (${humanSize})
|
||||
|
||||
Are you sure to delete these chunks permanently?
|
||||
|
||||
Note: **Make sure to synchronise all devices before deletion.**
|
||||
|
||||
> [!Note]
|
||||
> This operation finally reduces the capacity of the remote.`;
|
||||
|
||||
if (deletedNotVacantChunks.length == 0) {
|
||||
this._notice("No deleted chunks found.");
|
||||
return;
|
||||
}
|
||||
if (await this.confirm("Delete Chunks", message, "Delete", "Cancel")) {
|
||||
const result = await this.database.bulkDocs(deletedNotVacantChunks);
|
||||
this.clearHash();
|
||||
this._notice(
|
||||
`Deleted chunks: ${result.filter((e) => "ok" in e).length} / ${deletedNotVacantChunks.length}`
|
||||
);
|
||||
} else {
|
||||
this._notice("Deletion operation is cancelled.");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Compact the database.
|
||||
* This method removes all deleted chunks that are not used in the database.
|
||||
* Make sure all devices are synchronized before running this method.
|
||||
*/
|
||||
async markUnusedChunks() {
|
||||
if (!this.isAvailable()) return;
|
||||
const { used, existing } = await this.allChunks();
|
||||
const existChunks = [...existing];
|
||||
const unusedChunks = existChunks.filter(([key, e]) => !used.has(e._id)).map(([key, e]) => e);
|
||||
const deleteChunks = unusedChunks.map((e) => ({
|
||||
...e,
|
||||
_deleted: true,
|
||||
}));
|
||||
const size = deleteChunks.reduce((acc, e) => acc + e.data.length, 0);
|
||||
const humanSize = sizeToHumanReadable(size);
|
||||
if (deleteChunks.length == 0) {
|
||||
this._notice("No unused chunks found.");
|
||||
return;
|
||||
}
|
||||
const message = `We have following chunks that are not used from any files.
|
||||
|
||||
- Chunks: ${deleteChunks.length} (${humanSize})
|
||||
|
||||
Are you sure to mark these chunks to be deleted?
|
||||
|
||||
Note: **Make sure to synchronise all devices before deletion.**
|
||||
|
||||
> [!Note]
|
||||
> This operation will not reduces the capacity of the remote until permanent deletion.`;
|
||||
|
||||
if (await this.confirm("Mark unused chunks", message, "Mark", "Cancel")) {
|
||||
const result = await this.database.bulkDocs(deleteChunks);
|
||||
this.clearHash();
|
||||
this._notice(`Marked chunks: ${result.filter((e) => "ok" in e).length} / ${deleteChunks.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
async removeUnusedChunks() {
|
||||
const { used, existing } = await this.allChunks();
|
||||
const existChunks = [...existing];
|
||||
const unusedChunks = existChunks.filter(([key, e]) => !used.has(e._id)).map(([key, e]) => e);
|
||||
const deleteChunks = unusedChunks.map((e) => ({
|
||||
...e,
|
||||
data: "",
|
||||
_deleted: true,
|
||||
}));
|
||||
const size = unusedChunks.reduce((acc, e) => acc + e.data.length, 0);
|
||||
const humanSize = sizeToHumanReadable(size);
|
||||
if (deleteChunks.length == 0) {
|
||||
this._notice("No unused chunks found.");
|
||||
return;
|
||||
}
|
||||
const message = `We have following chunks that are not used from any files.
|
||||
|
||||
- Chunks: ${deleteChunks.length} (${humanSize})
|
||||
|
||||
Are you sure to delete these chunks?
|
||||
|
||||
Note: **Make sure to synchronise all devices before deletion.**
|
||||
|
||||
> [!Note]
|
||||
> Chunks referenced from deleted files are not deleted. Please run "Commit File Deletion" before this operation.`;
|
||||
|
||||
if (await this.confirm("Mark unused chunks", message, "Mark", "Cancel")) {
|
||||
const result = await this.database.bulkDocs(deleteChunks);
|
||||
this._notice(`Deleted chunks: ${result.filter((e) => "ok" in e).length} / ${deleteChunks.length}`);
|
||||
this.clearHash();
|
||||
}
|
||||
}
|
||||
}
|
||||
179
src/features/P2PSync/CmdP2PReplicator.ts
Normal file
179
src/features/P2PSync/CmdP2PReplicator.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { IObsidianModule } from "../../modules/AbstractObsidianModule";
|
||||
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "./P2PReplicator/P2PReplicatorPaneView.ts";
|
||||
import {
|
||||
AutoAccepting,
|
||||
LOG_LEVEL_NOTICE,
|
||||
REMOTE_P2P,
|
||||
type EntryDoc,
|
||||
type P2PSyncSetting,
|
||||
type RemoteDBSettings,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { LiveSyncCommands } from "../LiveSyncCommands.ts";
|
||||
import { LiveSyncTrysteroReplicator } from "../../lib/src/replication/trystero/LiveSyncTrysteroReplicator.ts";
|
||||
import { EVENT_REQUEST_OPEN_P2P, eventHub } from "../../common/events.ts";
|
||||
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator.ts";
|
||||
import { Logger } from "octagonal-wheels/common/logger";
|
||||
import type { CommandShim } from "../../lib/src/replication/trystero/P2PReplicatorPaneCommon.ts";
|
||||
import {
|
||||
P2PReplicatorMixIn,
|
||||
removeP2PReplicatorInstance,
|
||||
type P2PReplicatorBase,
|
||||
} from "../../lib/src/replication/trystero/P2PReplicatorCore.ts";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
|
||||
import type { Confirm } from "../../lib/src/interfaces/Confirm.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../../main.ts";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { getPlatformName } from "../../lib/src/PlatformAPIs/obsidian/Environment.ts";
|
||||
|
||||
class P2PReplicatorCommandBase extends LiveSyncCommands implements P2PReplicatorBase {
|
||||
storeP2PStatusLine = reactiveSource("");
|
||||
|
||||
getSettings(): P2PSyncSetting {
|
||||
return this.plugin.settings;
|
||||
}
|
||||
get settings() {
|
||||
return this.plugin.settings;
|
||||
}
|
||||
getDB() {
|
||||
return this.plugin.localDatabase.localDatabase;
|
||||
}
|
||||
|
||||
get confirm(): Confirm {
|
||||
return this.plugin.confirm;
|
||||
}
|
||||
_simpleStore!: SimpleStore<any>;
|
||||
|
||||
simpleStore(): SimpleStore<any> {
|
||||
return this._simpleStore;
|
||||
}
|
||||
|
||||
constructor(plugin: ObsidianLiveSyncPlugin) {
|
||||
super(plugin);
|
||||
}
|
||||
|
||||
async handleReplicatedDocuments(docs: EntryDoc[]): Promise<void> {
|
||||
// console.log("Processing Replicated Docs", docs);
|
||||
return await this.plugin.$$parseReplicationResult(docs as PouchDB.Core.ExistingDocument<EntryDoc>[]);
|
||||
}
|
||||
onunload(): void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
onload(): void | Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
init() {
|
||||
this._simpleStore = this.plugin.$$getSimpleStore("p2p-sync");
|
||||
return Promise.resolve(this);
|
||||
}
|
||||
}
|
||||
|
||||
export class P2PReplicator
|
||||
extends P2PReplicatorMixIn(P2PReplicatorCommandBase)
|
||||
implements IObsidianModule, CommandShim
|
||||
{
|
||||
storeP2PStatusLine = reactiveSource("");
|
||||
$anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_P2P) {
|
||||
return Promise.resolve(new LiveSyncTrysteroReplicator(this.plugin));
|
||||
}
|
||||
return undefined!;
|
||||
}
|
||||
override getPlatform(): string {
|
||||
return getPlatformName();
|
||||
}
|
||||
|
||||
override onunload(): void {
|
||||
removeP2PReplicatorInstance();
|
||||
void this.close();
|
||||
}
|
||||
|
||||
override onload(): void | Promise<void> {
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => {
|
||||
void this.openPane();
|
||||
});
|
||||
this.p2pLogCollector.p2pReplicationLine.onChanged((line) => {
|
||||
this.storeP2PStatusLine.value = line.value;
|
||||
});
|
||||
}
|
||||
async $everyOnInitializeDatabase(): Promise<boolean> {
|
||||
await this.initialiseP2PReplicator();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async $allSuspendExtraSync() {
|
||||
this.plugin.settings.P2P_Enabled = false;
|
||||
this.plugin.settings.P2P_AutoAccepting = AutoAccepting.NONE;
|
||||
this.plugin.settings.P2P_AutoBroadcast = false;
|
||||
this.plugin.settings.P2P_AutoStart = false;
|
||||
this.plugin.settings.P2P_AutoSyncPeers = "";
|
||||
this.plugin.settings.P2P_AutoWatchPeers = "";
|
||||
return await Promise.resolve(true);
|
||||
}
|
||||
|
||||
async $everyOnLoadStart() {
|
||||
return await Promise.resolve();
|
||||
}
|
||||
|
||||
async openPane() {
|
||||
await this.plugin.$$showView(VIEW_TYPE_P2P);
|
||||
}
|
||||
|
||||
async $everyOnloadStart(): Promise<boolean> {
|
||||
this.plugin.registerView(VIEW_TYPE_P2P, (leaf) => new P2PReplicatorPaneView(leaf, this.plugin));
|
||||
this.plugin.addCommand({
|
||||
id: "open-p2p-replicator",
|
||||
name: "P2P Sync : Open P2P Replicator",
|
||||
callback: async () => {
|
||||
await this.openPane();
|
||||
},
|
||||
});
|
||||
this.plugin.addCommand({
|
||||
id: "p2p-establish-connection",
|
||||
name: "P2P Sync : Connect to the Signalling Server",
|
||||
checkCallback: (isChecking) => {
|
||||
if (isChecking) {
|
||||
return !(this._replicatorInstance?.server?.isServing ?? false);
|
||||
}
|
||||
void this.open();
|
||||
},
|
||||
});
|
||||
this.plugin.addCommand({
|
||||
id: "p2p-close-connection",
|
||||
name: "P2P Sync : Disconnect from the Signalling Server",
|
||||
checkCallback: (isChecking) => {
|
||||
if (isChecking) {
|
||||
return this._replicatorInstance?.server?.isServing ?? false;
|
||||
}
|
||||
Logger(`Closing P2P Connection`, LOG_LEVEL_NOTICE);
|
||||
void this.close();
|
||||
},
|
||||
});
|
||||
this.plugin.addCommand({
|
||||
id: "replicate-now-by-p2p",
|
||||
name: "Replicate now by P2P",
|
||||
checkCallback: (isChecking) => {
|
||||
if (isChecking) {
|
||||
if (this.settings.remoteType == REMOTE_P2P) return false;
|
||||
if (!this._replicatorInstance?.server?.isServing) return false;
|
||||
return true;
|
||||
}
|
||||
void this._replicatorInstance?.replicateFromCommand(false);
|
||||
},
|
||||
});
|
||||
this.plugin
|
||||
.addRibbonIcon("waypoints", "P2P Replicator", async () => {
|
||||
await this.openPane();
|
||||
})
|
||||
.addClass("livesync-ribbon-replicate-p2p");
|
||||
|
||||
return await Promise.resolve(true);
|
||||
}
|
||||
$everyAfterResumeProcess(): Promise<boolean> {
|
||||
if (this.settings.P2P_Enabled && this.settings.P2P_AutoStart) {
|
||||
setTimeout(() => void this.open(), 100);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
481
src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte
Normal file
481
src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte
Normal file
@@ -0,0 +1,481 @@
|
||||
<script lang="ts">
|
||||
import { onMount, setContext } from "svelte";
|
||||
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "../../../lib/src/common/types";
|
||||
import {
|
||||
AcceptedStatus,
|
||||
ConnectionStatus,
|
||||
type CommandShim,
|
||||
type PeerStatus,
|
||||
type PluginShim,
|
||||
} from "../../../lib/src/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import PeerStatusRow from "../P2PReplicator/PeerStatusRow.svelte";
|
||||
import { EVENT_LAYOUT_READY, eventHub } from "../../../common/events";
|
||||
import {
|
||||
type PeerInfo,
|
||||
type P2PServerInfo,
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_REQUEST_STATUS,
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
} from "../../../lib/src/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { type P2PReplicatorStatus } from "../../../lib/src/replication/trystero/TrysteroReplicator";
|
||||
import { $msg as _msg } from "../../../lib/src/common/i18n";
|
||||
|
||||
interface Props {
|
||||
plugin: PluginShim;
|
||||
cmdSync: CommandShim;
|
||||
}
|
||||
|
||||
let { plugin, cmdSync }: Props = $props();
|
||||
// const cmdSync = plugin.getAddOn<P2PReplicator>("P2PReplicator")!;
|
||||
setContext("getReplicator", () => cmdSync);
|
||||
|
||||
const initialSettings = { ...plugin.settings };
|
||||
|
||||
let settings = $state<P2PSyncSetting>(initialSettings);
|
||||
// const vaultName = plugin.$$getVaultName();
|
||||
// const dbKey = `${vaultName}-p2p-device-name`;
|
||||
|
||||
const initialDeviceName = cmdSync.getConfig("p2p_device_name") ?? plugin.$$getVaultName();
|
||||
let deviceName = $state<string>(initialDeviceName);
|
||||
|
||||
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
|
||||
let eRelay = $state<string>(initialSettings.P2P_relays);
|
||||
let eRoomId = $state<string>(initialSettings.P2P_roomID);
|
||||
let ePassword = $state<string>(initialSettings.P2P_passphrase);
|
||||
let eAppId = $state<string>(initialSettings.P2P_AppID);
|
||||
let eDeviceName = $state<string>(initialDeviceName);
|
||||
let eAutoAccept = $state<boolean>(initialSettings.P2P_AutoAccepting == AutoAccepting.ALL);
|
||||
let eAutoStart = $state<boolean>(initialSettings.P2P_AutoStart);
|
||||
let eAutoBroadcast = $state<boolean>(initialSettings.P2P_AutoBroadcast);
|
||||
|
||||
const isP2PEnabledModified = $derived.by(() => eP2PEnabled !== settings.P2P_Enabled);
|
||||
const isRelayModified = $derived.by(() => eRelay !== settings.P2P_relays);
|
||||
const isRoomIdModified = $derived.by(() => eRoomId !== settings.P2P_roomID);
|
||||
const isPasswordModified = $derived.by(() => ePassword !== settings.P2P_passphrase);
|
||||
const isAppIdModified = $derived.by(() => eAppId !== settings.P2P_AppID);
|
||||
const isDeviceNameModified = $derived.by(() => eDeviceName !== deviceName);
|
||||
const isAutoAcceptModified = $derived.by(() => eAutoAccept !== (settings.P2P_AutoAccepting == AutoAccepting.ALL));
|
||||
const isAutoStartModified = $derived.by(() => eAutoStart !== settings.P2P_AutoStart);
|
||||
const isAutoBroadcastModified = $derived.by(() => eAutoBroadcast !== settings.P2P_AutoBroadcast);
|
||||
|
||||
const isAnyModified = $derived.by(
|
||||
() =>
|
||||
isP2PEnabledModified ||
|
||||
isRelayModified ||
|
||||
isRoomIdModified ||
|
||||
isPasswordModified ||
|
||||
isAppIdModified ||
|
||||
isDeviceNameModified ||
|
||||
isAutoAcceptModified ||
|
||||
isAutoStartModified ||
|
||||
isAutoBroadcastModified
|
||||
);
|
||||
|
||||
async function saveAndApply() {
|
||||
const newSettings = {
|
||||
...plugin.settings,
|
||||
P2P_Enabled: eP2PEnabled,
|
||||
P2P_relays: eRelay,
|
||||
P2P_roomID: eRoomId,
|
||||
P2P_passphrase: ePassword,
|
||||
P2P_AppID: eAppId,
|
||||
P2P_AutoAccepting: eAutoAccept ? AutoAccepting.ALL : AutoAccepting.NONE,
|
||||
P2P_AutoStart: eAutoStart,
|
||||
P2P_AutoBroadcast: eAutoBroadcast,
|
||||
};
|
||||
plugin.settings = newSettings;
|
||||
cmdSync.setConfig("p2p_device_name", eDeviceName);
|
||||
deviceName = eDeviceName;
|
||||
await plugin.saveSettings();
|
||||
}
|
||||
async function revert() {
|
||||
eP2PEnabled = settings.P2P_Enabled;
|
||||
eRelay = settings.P2P_relays;
|
||||
eRoomId = settings.P2P_roomID;
|
||||
ePassword = settings.P2P_passphrase;
|
||||
eAppId = settings.P2P_AppID;
|
||||
eAutoAccept = settings.P2P_AutoAccepting == AutoAccepting.ALL;
|
||||
eAutoStart = settings.P2P_AutoStart;
|
||||
eAutoBroadcast = settings.P2P_AutoBroadcast;
|
||||
}
|
||||
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
|
||||
const { P2P_relays, P2P_roomID, P2P_passphrase, P2P_AppID, P2P_AutoAccepting } = d;
|
||||
if (force || !isP2PEnabledModified) eP2PEnabled = d.P2P_Enabled;
|
||||
if (force || !isRelayModified) eRelay = P2P_relays;
|
||||
if (force || !isRoomIdModified) eRoomId = P2P_roomID;
|
||||
if (force || !isPasswordModified) ePassword = P2P_passphrase;
|
||||
if (force || !isAppIdModified) eAppId = P2P_AppID;
|
||||
const newAutoAccept = P2P_AutoAccepting === AutoAccepting.ALL;
|
||||
if (force || !isAutoAcceptModified) eAutoAccept = newAutoAccept;
|
||||
if (force || !isAutoStartModified) eAutoStart = d.P2P_AutoStart;
|
||||
if (force || !isAutoBroadcastModified) eAutoBroadcast = d.P2P_AutoBroadcast;
|
||||
|
||||
settings = d;
|
||||
};
|
||||
onMount(() => {
|
||||
const r = eventHub.onEvent("setting-saved", async (d) => {
|
||||
applyLoadSettings(d, false);
|
||||
closeServer();
|
||||
});
|
||||
const rx = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
|
||||
applyLoadSettings(plugin.settings, true);
|
||||
});
|
||||
const r2 = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
serverInfo = status;
|
||||
advertisements = status?.knownAdvertisements ?? [];
|
||||
});
|
||||
const r3 = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||
replicatorInfo = status;
|
||||
});
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
return () => {
|
||||
r();
|
||||
r2();
|
||||
r3();
|
||||
};
|
||||
});
|
||||
let isConnected = $derived.by(() => {
|
||||
return serverInfo?.isConnected ?? false;
|
||||
});
|
||||
let serverPeerId = $derived.by(() => {
|
||||
return serverInfo?.serverPeerId ?? "";
|
||||
});
|
||||
let advertisements = $state<PeerInfo[]>([]);
|
||||
|
||||
let autoSyncPeers = $derived.by(() =>
|
||||
settings.P2P_AutoSyncPeers.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e)
|
||||
);
|
||||
let autoWatchPeers = $derived.by(() =>
|
||||
settings.P2P_AutoWatchPeers.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e)
|
||||
);
|
||||
let syncOnCommand = $derived.by(() =>
|
||||
settings.P2P_SyncOnReplication.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e)
|
||||
);
|
||||
|
||||
const peers = $derived.by(() =>
|
||||
advertisements.map((ad) => {
|
||||
let accepted: AcceptedStatus;
|
||||
const isTemporaryAccepted = ad.isTemporaryAccepted;
|
||||
if (isTemporaryAccepted === undefined) {
|
||||
if (ad.isAccepted === undefined) {
|
||||
accepted = AcceptedStatus.UNKNOWN;
|
||||
} else {
|
||||
accepted = ad.isAccepted ? AcceptedStatus.ACCEPTED : AcceptedStatus.DENIED;
|
||||
}
|
||||
} else if (isTemporaryAccepted === true) {
|
||||
accepted = AcceptedStatus.ACCEPTED_IN_SESSION;
|
||||
} else {
|
||||
accepted = AcceptedStatus.DENIED_IN_SESSION;
|
||||
}
|
||||
const isFetching = replicatorInfo?.replicatingFrom.indexOf(ad.peerId) !== -1;
|
||||
const isSending = replicatorInfo?.replicatingTo.indexOf(ad.peerId) !== -1;
|
||||
const isWatching = replicatorInfo?.watchingPeers.indexOf(ad.peerId) !== -1;
|
||||
const syncOnStart = autoSyncPeers.indexOf(ad.name) !== -1;
|
||||
const watchOnStart = autoWatchPeers.indexOf(ad.name) !== -1;
|
||||
const syncOnReplicationCommand = syncOnCommand.indexOf(ad.name) !== -1;
|
||||
const st: PeerStatus = {
|
||||
name: ad.name,
|
||||
peerId: ad.peerId,
|
||||
accepted: accepted,
|
||||
status: ad.isAccepted ? ConnectionStatus.CONNECTED : ConnectionStatus.DISCONNECTED,
|
||||
isSending: isSending,
|
||||
isFetching: isFetching,
|
||||
isWatching: isWatching,
|
||||
syncOnConnect: syncOnStart,
|
||||
watchOnConnect: watchOnStart,
|
||||
syncOnReplicationCommand: syncOnReplicationCommand,
|
||||
};
|
||||
return st;
|
||||
})
|
||||
);
|
||||
|
||||
function useDefaultRelay() {
|
||||
eRelay = DEFAULT_SETTINGS.P2P_relays;
|
||||
}
|
||||
function _generateRandom() {
|
||||
return (Math.floor(Math.random() * 1000) + 1000).toString().substring(1);
|
||||
}
|
||||
function generateRandom(length: number) {
|
||||
let buf = "";
|
||||
while (buf.length < length) {
|
||||
buf += "-" + _generateRandom();
|
||||
}
|
||||
return buf.substring(1, length);
|
||||
}
|
||||
function chooseRandom() {
|
||||
eRoomId = generateRandom(12) + "-" + Math.random().toString(36).substring(2, 5);
|
||||
}
|
||||
|
||||
async function openServer() {
|
||||
await cmdSync.open();
|
||||
}
|
||||
async function closeServer() {
|
||||
await cmdSync.close();
|
||||
}
|
||||
function startBroadcasting() {
|
||||
void cmdSync.enableBroadcastCastings();
|
||||
}
|
||||
function stopBroadcasting() {
|
||||
void cmdSync.disableBroadcastCastings();
|
||||
}
|
||||
|
||||
const initialDialogStatusKey = `p2p-dialog-status`;
|
||||
const getDialogStatus = () => {
|
||||
try {
|
||||
const initialDialogStatus = JSON.parse(cmdSync.getConfig(initialDialogStatusKey) ?? "{}") as {
|
||||
notice?: boolean;
|
||||
setting?: boolean;
|
||||
};
|
||||
return initialDialogStatus;
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
const initialDialogStatus = getDialogStatus();
|
||||
let isNoticeOpened = $state<boolean>(initialDialogStatus.notice ?? true);
|
||||
let isSettingOpened = $state<boolean>(initialDialogStatus.setting ?? true);
|
||||
$effect(() => {
|
||||
const dialogStatus = {
|
||||
notice: isNoticeOpened,
|
||||
setting: isSettingOpened,
|
||||
};
|
||||
cmdSync.setConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
|
||||
});
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<h1>Peer to Peer Replicator</h1>
|
||||
<details bind:open={isNoticeOpened}>
|
||||
<summary>{_msg("P2P.Note.Summary")}</summary>
|
||||
<p class="important">{_msg("P2P.Note.important_note")}</p>
|
||||
<p class="important-sub">
|
||||
{_msg("P2P.Note.important_note_sub")}
|
||||
</p>
|
||||
{#each _msg("P2P.Note.description").split("\n\n") as paragraph}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
</details>
|
||||
<h2>Connection Settings</h2>
|
||||
<details bind:open={isSettingOpened}>
|
||||
<summary>{eRelay}</summary>
|
||||
<table class="settings">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th> Enable P2P Replicator </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isP2PEnabledModified }}>
|
||||
<input type="checkbox" bind:checked={eP2PEnabled} />
|
||||
</label>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<th> Relay settings </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isRelayModified }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="wss://exp-relay.vrtmrz.net, wss://xxxxx"
|
||||
bind:value={eRelay}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button onclick={() => useDefaultRelay()}> Use vrtmrz's relay </button>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Room ID </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isRoomIdModified }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="anything-you-like"
|
||||
bind:value={eRoomId}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
/>
|
||||
<button onclick={() => chooseRandom()}> Use Random Number </button>
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
This can isolate your connections between devices. Use the same Room ID for the same
|
||||
devices.</small
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Password </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isPasswordModified }}>
|
||||
<input type="password" placeholder="password" bind:value={ePassword} />
|
||||
</label>
|
||||
<span>
|
||||
<small> This password is used to encrypt the connection. Use something long enough. </small>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> This device name </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isDeviceNameModified }}>
|
||||
<input type="text" placeholder="iphone-16" bind:value={eDeviceName} autocomplete="off" />
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
Device name to identify the device. Please use shorter one for the stable peer
|
||||
detection, i.e., "iphone-16" or "macbook-2021".
|
||||
</small>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Auto Connect </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoStartModified }}>
|
||||
<input type="checkbox" bind:checked={eAutoStart} />
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Start change-broadcasting on Connect </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoBroadcastModified }}>
|
||||
<input type="checkbox" bind:checked={eAutoBroadcast} />
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- <tr>
|
||||
<th> Auto Accepting </th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoAcceptModified }}>
|
||||
<input type="checkbox" bind:checked={eAutoAccept} />
|
||||
</label>
|
||||
</td>
|
||||
</tr> -->
|
||||
</tbody>
|
||||
</table>
|
||||
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}>Save and Apply</button>
|
||||
<button disabled={!isAnyModified} class="button" onclick={revert}>Revert changes</button>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<h2>Signaling Server Connection</h2>
|
||||
<div>
|
||||
{#if !isConnected}
|
||||
<p>No Connection</p>
|
||||
{:else}
|
||||
<p>Connected to Signaling Server (as Peer ID: {serverPeerId})</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if !isConnected}
|
||||
<button onclick={openServer}>Connect</button>
|
||||
{:else}
|
||||
<button onclick={closeServer}>Disconnect</button>
|
||||
{#if replicatorInfo?.isBroadcasting !== undefined}
|
||||
{#if replicatorInfo?.isBroadcasting}
|
||||
<button onclick={stopBroadcasting}>Stop Broadcasting</button>
|
||||
{:else}
|
||||
<button onclick={startBroadcasting}>Start Broadcasting</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<details>
|
||||
<summary>Broadcasting?</summary>
|
||||
<p>
|
||||
<small>
|
||||
If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which
|
||||
detects this will start the replication for fetching. <br />
|
||||
However, This should not be enabled if you want to increase your secrecy more.
|
||||
</small>
|
||||
</p>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Peers</h2>
|
||||
<table class="peers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Action</th>
|
||||
<th>Command</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each peers as peer}
|
||||
<PeerStatusRow peerStatus={peer}></PeerStatusRow>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article {
|
||||
max-width: 100%;
|
||||
}
|
||||
article p {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
h2 {
|
||||
margin-top: var(--size-4-1);
|
||||
margin-bottom: var(--size-4-1);
|
||||
padding-bottom: var(--size-4-1);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
label.is-dirty {
|
||||
background-color: var(--background-modifier-error);
|
||||
}
|
||||
input {
|
||||
background-color: transparent;
|
||||
}
|
||||
th {
|
||||
/* display: flex;
|
||||
justify-content: center;
|
||||
align-items: center; */
|
||||
min-height: var(--input-height);
|
||||
}
|
||||
td {
|
||||
min-height: var(--input-height);
|
||||
}
|
||||
td > label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: var(--input-height);
|
||||
}
|
||||
td > label > * {
|
||||
margin: auto var(--size-4-1);
|
||||
}
|
||||
table.peers {
|
||||
width: 100%;
|
||||
}
|
||||
.important {
|
||||
color: var(--text-error);
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.important-sub {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
.settings label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
198
src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts
Normal file
198
src/features/P2PSync/P2PReplicator/P2PReplicatorPaneView.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Menu, WorkspaceLeaf } from "obsidian";
|
||||
import ReplicatorPaneComponent from "./P2PReplicatorPane.svelte";
|
||||
import type ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import { mount } from "svelte";
|
||||
import { SvelteItemView } from "../../../common/SvelteItemView.ts";
|
||||
import { eventHub } from "../../../common/events.ts";
|
||||
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { LOG_LEVEL_NOTICE, REMOTE_P2P } from "../../../lib/src/common/types.ts";
|
||||
import { Logger } from "../../../lib/src/common/logger.ts";
|
||||
import { P2PReplicator } from "../CmdP2PReplicator.ts";
|
||||
import {
|
||||
EVENT_P2P_PEER_SHOW_EXTRA_MENU,
|
||||
type PeerStatus,
|
||||
} from "../../../lib/src/replication/trystero/P2PReplicatorPaneCommon.ts";
|
||||
export const VIEW_TYPE_P2P = "p2p-replicator";
|
||||
|
||||
function addToList(item: string, list: string) {
|
||||
return unique(
|
||||
list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.concat(item)
|
||||
.filter((p) => p)
|
||||
).join(",");
|
||||
}
|
||||
function removeFromList(item: string, list: string) {
|
||||
return list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((p) => p !== item)
|
||||
.filter((p) => p)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export class P2PReplicatorPaneView extends SvelteItemView {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon = "waypoints";
|
||||
title: string = "";
|
||||
navigation = false;
|
||||
|
||||
getIcon(): string {
|
||||
return "waypoints";
|
||||
}
|
||||
get replicator() {
|
||||
const r = this.plugin.getAddOn<P2PReplicator>(P2PReplicator.name);
|
||||
if (!r || !r._replicatorInstance) {
|
||||
throw new Error("Replicator not found");
|
||||
}
|
||||
return r._replicatorInstance;
|
||||
}
|
||||
async replicateFrom(peer: PeerStatus) {
|
||||
await this.replicator.replicateFrom(peer.peerId);
|
||||
}
|
||||
async replicateTo(peer: PeerStatus) {
|
||||
await this.replicator.requestSynchroniseToPeer(peer.peerId);
|
||||
}
|
||||
async getRemoteConfig(peer: PeerStatus) {
|
||||
Logger(
|
||||
`Requesting remote config for ${peer.name}. Please input the passphrase on the remote device`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const remoteConfig = await this.replicator.getRemoteConfig(peer.peerId);
|
||||
if (remoteConfig) {
|
||||
Logger(`Remote config for ${peer.name} is retrieved successfully`);
|
||||
const DROP = "Yes, and drop local database";
|
||||
const KEEP = "Yes, but keep local database";
|
||||
const CANCEL = "No, cancel";
|
||||
const yn = await this.plugin.confirm.askSelectStringDialogue(
|
||||
`Do you really want to apply the remote config? This will overwrite your current config immediately and restart.
|
||||
And you can also drop the local database to rebuild from the remote device.`,
|
||||
[DROP, KEEP, CANCEL] as const,
|
||||
{
|
||||
defaultAction: CANCEL,
|
||||
title: "Apply Remote Config ",
|
||||
}
|
||||
);
|
||||
if (yn === DROP || yn === KEEP) {
|
||||
if (yn === DROP) {
|
||||
if (remoteConfig.remoteType !== REMOTE_P2P) {
|
||||
const yn2 = await this.plugin.confirm.askYesNoDialog(
|
||||
`Do you want to set the remote type to "P2P Sync" to rebuild by "P2P replication"?`,
|
||||
{
|
||||
title: "Rebuild from remote device",
|
||||
}
|
||||
);
|
||||
if (yn2 === "yes") {
|
||||
remoteConfig.remoteType = REMOTE_P2P;
|
||||
remoteConfig.P2P_RebuildFrom = peer.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.plugin.settings = remoteConfig;
|
||||
await this.plugin.saveSettings();
|
||||
if (yn === DROP) {
|
||||
await this.plugin.rebuilder.scheduleFetch();
|
||||
} else {
|
||||
await this.plugin.$$scheduleAppReload();
|
||||
}
|
||||
} else {
|
||||
Logger(`Cancelled\nRemote config for ${peer.name} is not applied`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
} else {
|
||||
Logger(`Cannot retrieve remote config for ${peer.peerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleProp(peer: PeerStatus, prop: "syncOnConnect" | "watchOnConnect" | "syncOnReplicationCommand") {
|
||||
const settingMap = {
|
||||
syncOnConnect: "P2P_AutoSyncPeers",
|
||||
watchOnConnect: "P2P_AutoWatchPeers",
|
||||
syncOnReplicationCommand: "P2P_SyncOnReplication",
|
||||
} as const;
|
||||
|
||||
const targetSetting = settingMap[prop];
|
||||
if (peer[prop]) {
|
||||
this.plugin.settings[targetSetting] = removeFromList(peer.name, this.plugin.settings[targetSetting]);
|
||||
await this.plugin.saveSettings();
|
||||
} else {
|
||||
this.plugin.settings[targetSetting] = addToList(peer.name, this.plugin.settings[targetSetting]);
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
m?: Menu;
|
||||
constructor(leaf: WorkspaceLeaf, plugin: ObsidianLiveSyncPlugin) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
eventHub.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => {
|
||||
if (this.m) {
|
||||
this.m.hide();
|
||||
}
|
||||
this.m = new Menu()
|
||||
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer)))
|
||||
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer)))
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("🔧 Get Configuration").onClick(async () => {
|
||||
await this.getRemoteConfig(peer);
|
||||
});
|
||||
})
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.watchOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Watch on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "watchOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnReplicationCommand ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on `Replicate now` command")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnReplicationCommand");
|
||||
})
|
||||
.setIcon(mark);
|
||||
});
|
||||
this.m.showAtPosition({ x: event.x, y: event.y });
|
||||
});
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_P2P;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Peer-to-Peer Replicator";
|
||||
}
|
||||
|
||||
override async onClose(): Promise<void> {
|
||||
await super.onClose();
|
||||
if (this.m) {
|
||||
this.m.hide();
|
||||
}
|
||||
}
|
||||
instantiateComponent(target: HTMLElement) {
|
||||
const cmdSync = this.plugin.getAddOn<P2PReplicator>(P2PReplicator.name);
|
||||
if (!cmdSync) {
|
||||
throw new Error("Replicator not found");
|
||||
}
|
||||
return mount(ReplicatorPaneComponent, {
|
||||
target: target,
|
||||
props: {
|
||||
plugin: cmdSync.plugin,
|
||||
cmdSync: cmdSync,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
259
src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte
Normal file
259
src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte
Normal file
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { AcceptedStatus, type PeerStatus } from "../../../lib/src/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { P2PReplicator } from "../CmdP2PReplicator";
|
||||
import { eventHub } from "../../../common/events";
|
||||
import { EVENT_P2P_PEER_SHOW_EXTRA_MENU } from "../../../lib/src/replication/trystero/P2PReplicatorPaneCommon";
|
||||
|
||||
interface Props {
|
||||
peerStatus: PeerStatus;
|
||||
}
|
||||
|
||||
let { peerStatus }: Props = $props();
|
||||
let peer = $derived(peerStatus);
|
||||
|
||||
function select<T extends string | number | symbol, U>(d: T, cond: Record<T, U>): U;
|
||||
function select<T extends string | number | symbol, U, V>(d: T, cond: Record<T, U>, def: V): U | V;
|
||||
function select<T extends string | number | symbol, U>(d: T, cond: Record<T, U>, def?: U): U | undefined {
|
||||
return d in cond ? cond[d] : def;
|
||||
}
|
||||
|
||||
let statusChips = $derived.by(() =>
|
||||
[
|
||||
peer.isWatching ? ["WATCHING"] : [],
|
||||
peer.isFetching ? ["FETCHING"] : [],
|
||||
peer.isSending ? ["SENDING"] : [],
|
||||
].flat()
|
||||
);
|
||||
let acceptedStatusChip = $derived.by(() =>
|
||||
select(
|
||||
peer.accepted.toString(),
|
||||
{
|
||||
[AcceptedStatus.ACCEPTED]: "ACCEPTED",
|
||||
[AcceptedStatus.ACCEPTED_IN_SESSION]: "ACCEPTED (in session)",
|
||||
[AcceptedStatus.DENIED_IN_SESSION]: "DENIED (in session)",
|
||||
[AcceptedStatus.DENIED]: "DENIED",
|
||||
[AcceptedStatus.UNKNOWN]: "NEW",
|
||||
},
|
||||
""
|
||||
)
|
||||
);
|
||||
const classList = {
|
||||
["SENDING"]: "connected",
|
||||
["FETCHING"]: "connected",
|
||||
["WATCHING"]: "connected-live",
|
||||
["WAITING"]: "waiting",
|
||||
["ACCEPTED"]: "accepted",
|
||||
["DENIED"]: "denied",
|
||||
["NEW"]: "unknown",
|
||||
};
|
||||
let isAccepted = $derived.by(
|
||||
() => peer.accepted === AcceptedStatus.ACCEPTED || peer.accepted === AcceptedStatus.ACCEPTED_IN_SESSION
|
||||
);
|
||||
let isDenied = $derived.by(
|
||||
() => peer.accepted === AcceptedStatus.DENIED || peer.accepted === AcceptedStatus.DENIED_IN_SESSION
|
||||
);
|
||||
|
||||
let isNew = $derived.by(() => peer.accepted === AcceptedStatus.UNKNOWN);
|
||||
|
||||
function makeDecision(isAccepted: boolean, isTemporary: boolean) {
|
||||
cmdReplicator._replicatorInstance?.server?.makeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
decision: isAccepted,
|
||||
isTemporary: isTemporary,
|
||||
});
|
||||
}
|
||||
function revokeDecision() {
|
||||
cmdReplicator._replicatorInstance?.server?.revokeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
});
|
||||
}
|
||||
const cmdReplicator = getContext<() => P2PReplicator>("getReplicator")();
|
||||
const replicator = cmdReplicator._replicatorInstance!;
|
||||
|
||||
const peerAttrLabels = $derived.by(() => {
|
||||
const attrs = [];
|
||||
if (peer.syncOnConnect) {
|
||||
attrs.push("✔ SYNC");
|
||||
}
|
||||
if (peer.watchOnConnect) {
|
||||
attrs.push("✔ WATCH");
|
||||
}
|
||||
if (peer.syncOnReplicationCommand) {
|
||||
attrs.push("✔ SELECT");
|
||||
}
|
||||
return attrs;
|
||||
});
|
||||
function startWatching() {
|
||||
replicator.watchPeer(peer.peerId);
|
||||
}
|
||||
function stopWatching() {
|
||||
replicator.unwatchPeer(peer.peerId);
|
||||
}
|
||||
|
||||
function sync() {
|
||||
replicator.sync(peer.peerId, false);
|
||||
}
|
||||
|
||||
function moreMenu(evt: MouseEvent) {
|
||||
eventHub.emitEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, { peer, event: evt });
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<div class="info">
|
||||
<div class="row name">
|
||||
<span class="peername">{peer.name}</span>
|
||||
</div>
|
||||
<div class="row peer-id">
|
||||
<span class="peerid">({peer.peerId})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-chips">
|
||||
<div class="row">
|
||||
<span class="chip {select(acceptedStatusChip, classList)}">{acceptedStatusChip}</span>
|
||||
</div>
|
||||
{#if isAccepted}
|
||||
<div class="row">
|
||||
{#each statusChips as chip}
|
||||
<span class="chip {select(chip, classList)}">{chip}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row">
|
||||
{#each peerAttrLabels as attr}
|
||||
<span class="chip attr">{attr}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
<div class="row">
|
||||
{#if isNew}
|
||||
{#if !isAccepted}
|
||||
<button class="button" onclick={() => makeDecision(true, true)}>Accept in session</button>
|
||||
<button class="button mod-cta" onclick={() => makeDecision(true, false)}>Accept</button>
|
||||
{/if}
|
||||
{#if !isDenied}
|
||||
<button class="button" onclick={() => makeDecision(false, true)}>Deny in session</button>
|
||||
<button class="button mod-warning" onclick={() => makeDecision(false, false)}>Deny</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button class="button mod-warning" onclick={() => revokeDecision()}>Revoke</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if isAccepted}
|
||||
<div class="buttons">
|
||||
<div class="row">
|
||||
<button class="button" onclick={sync} disabled={peer.isSending || peer.isFetching}>🔄</button>
|
||||
<!-- <button class="button" onclick={replicateFrom} disabled={peer.isFetching}>📥</button>
|
||||
<button class="button" onclick={replicateTo} disabled={peer.isSending}>📤</button> -->
|
||||
{#if peer.isWatching}
|
||||
<button class="button" onclick={stopWatching}>Stop ⚡</button>
|
||||
{:else}
|
||||
<button class="button" onclick={startWatching} title="live">⚡</button>
|
||||
{/if}
|
||||
<button class="button" onclick={moreMenu}>...</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<style>
|
||||
tr:nth-child(odd) {
|
||||
background-color: var(--background-primary-alt);
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--size-4-1) var(--size-4-1);
|
||||
}
|
||||
|
||||
.peer-id {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.status-chips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* min-width: 10em; */
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.buttons .row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
/* padding: var(--size-4-1) var(--size-4-1); */
|
||||
}
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
margin: 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
background-color: var(--tag-background);
|
||||
border: var(--tag-border-width) solid var(--tag-border-color);
|
||||
}
|
||||
.chip.connected {
|
||||
background-color: var(--background-modifier-success);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.chip.connected-live {
|
||||
background-color: var(--background-modifier-success);
|
||||
border-color: var(--background-modifier-success);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.chip.accepted {
|
||||
background-color: var(--background-modifier-success);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.chip.waiting {
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
.chip.unknown {
|
||||
background-color: var(--background-primary);
|
||||
color: var(--text-warning);
|
||||
}
|
||||
.chip.denied {
|
||||
background-color: var(--background-modifier-error);
|
||||
color: var(--text-error);
|
||||
}
|
||||
.chip.attr {
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
.button {
|
||||
margin: var(--size-4-1);
|
||||
}
|
||||
.button.affirmative {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.button.affirmative:hover {
|
||||
background-color: var(--interactive-accent-hover);
|
||||
}
|
||||
.button.negative {
|
||||
background-color: var(--background-modifier-error);
|
||||
color: var(--text-error);
|
||||
}
|
||||
.button.negative:hover {
|
||||
background-color: var(--background-modifier-error-hover);
|
||||
}
|
||||
</style>
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 92d7b03916...ad3f7ee995
721
src/main.ts
721
src/main.ts
@@ -1,25 +1,41 @@
|
||||
import { Plugin } from "./deps";
|
||||
import { type EntryDoc, type LoadedEntry, type ObsidianLiveSyncSettings, type LOG_LEVEL, VER, DEFAULT_SETTINGS, type diff_result, type DatabaseConnectingStatus, type EntryHasPath, type DocumentID, type FilePathWithPrefix, type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, type HasSettings, type MetaEntry, type UXFileInfoStub, type MISSING_OR_ERROR, type AUTO_MERGED, } from "./lib/src/common/types.ts";
|
||||
import {
|
||||
type EntryDoc,
|
||||
type LoadedEntry,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type LOG_LEVEL,
|
||||
type diff_result,
|
||||
type DatabaseConnectingStatus,
|
||||
type EntryHasPath,
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type FilePath,
|
||||
LOG_LEVEL_INFO,
|
||||
type HasSettings,
|
||||
type MetaEntry,
|
||||
type UXFileInfoStub,
|
||||
type MISSING_OR_ERROR,
|
||||
type AUTO_MERGED,
|
||||
type RemoteDBSettings,
|
||||
type TweakValues,
|
||||
type CouchDBCredentials,
|
||||
} from "./lib/src/common/types.ts";
|
||||
import { type FileEventItem } from "./common/types.ts";
|
||||
import { fireAndForget, type SimpleStore } from "./lib/src/common/utils.ts";
|
||||
import { Logger } from "./lib/src/common/logger.ts";
|
||||
import { cancelAllPeriodicTask, cancelAllTasks } from "./common/utils.ts";
|
||||
import { versionNumberString2Number } from "./lib/src/string_and_binary/convert.ts";
|
||||
import { type SimpleStore } from "./lib/src/common/utils.ts";
|
||||
import { LiveSyncLocalDB, type LiveSyncLocalDBEnv } from "./lib/src/pouchdb/LiveSyncLocalDB.ts";
|
||||
import { LiveSyncAbstractReplicator, type LiveSyncReplicatorEnv } from "./lib/src/replication/LiveSyncAbstractReplicator.js";
|
||||
import {
|
||||
LiveSyncAbstractReplicator,
|
||||
type LiveSyncReplicatorEnv,
|
||||
} from "./lib/src/replication/LiveSyncAbstractReplicator.js";
|
||||
import { type KeyValueDatabase } from "./common/KeyValueDB.ts";
|
||||
import { LiveSyncCommands } from "./features/LiveSyncCommands.ts";
|
||||
import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||
import { stopAllRunningProcessors } from "./lib/src/concurrency/processor.js";
|
||||
import { reactiveSource, type ReactiveValue } from "./lib/src/dataobject/reactive.js";
|
||||
import { type LiveSyncJournalReplicatorEnv } from "./lib/src/replication/journal/LiveSyncJournalReplicator.js";
|
||||
import { type LiveSyncCouchDBReplicatorEnv } from "./lib/src/replication/couchdb/LiveSyncReplicator.js";
|
||||
import type { CheckPointInfo } from "./lib/src/replication/journal/JournalSyncTypes.js";
|
||||
import { ObsHttpHandler } from "./modules/essentialObsidian/APILib/ObsHttpHandler.js";
|
||||
import { $f, setLang } from "./lib/src/common/i18n.ts";
|
||||
import { eventHub } from "./lib/src/hub/hub.ts";
|
||||
import { EVENT_LAYOUT_READY, EVENT_PLUGIN_LOADED, EVENT_PLUGIN_UNLOADED, EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED } from "./common/events.ts";
|
||||
import type { IObsidianModule } from "./modules/AbstractObsidianModule.ts";
|
||||
|
||||
import { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
||||
@@ -36,7 +52,7 @@ import { ModuleRedFlag } from "./modules/coreFeatures/ModuleRedFlag.ts";
|
||||
import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts";
|
||||
import { ModuleSetupObsidian } from "./modules/features/ModuleSetupObsidian.ts";
|
||||
import type { StorageAccess } from "./modules/interfaces/StorageAccess.ts";
|
||||
import type { Confirm } from "./modules/interfaces/Confirm.ts";
|
||||
import type { Confirm } from "./lib/src/interfaces/Confirm.ts";
|
||||
import type { Rebuilder } from "./modules/interfaces/DatabaseRebuilder.ts";
|
||||
import type { DatabaseFileAccess } from "./modules/interfaces/DatabaseFileAccess.ts";
|
||||
import { ModuleDatabaseFileAccess } from "./modules/core/ModuleDatabaseFileAccess.ts";
|
||||
@@ -64,7 +80,10 @@ import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleRe
|
||||
import { ModuleIntegratedTest } from "./modules/extras/ModuleIntegratedTest.ts";
|
||||
import { ModuleRebuilder } from "./modules/core/ModuleRebuilder.ts";
|
||||
import { ModuleReplicateTest } from "./modules/extras/ModuleReplicateTest.ts";
|
||||
|
||||
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain.ts";
|
||||
import { ModuleExtraSyncObsidian } from "./modules/extraFeaturesObsidian/ModuleExtraSyncObsidian.ts";
|
||||
import { LocalDatabaseMaintenance } from "./features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts";
|
||||
import { P2PReplicator } from "./features/P2PSync/CmdP2PReplicator.ts";
|
||||
|
||||
function throwShouldBeOverridden(): never {
|
||||
throw new Error("This function should be overridden by the module.");
|
||||
@@ -83,34 +102,15 @@ const InterceptiveAny = Promise.resolve(undefined);
|
||||
* All of above performed on injectModules function.
|
||||
*/
|
||||
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin implements LiveSyncLocalDBEnv, LiveSyncReplicatorEnv, LiveSyncJournalReplicatorEnv, LiveSyncCouchDBReplicatorEnv, HasSettings<ObsidianLiveSyncSettings> {
|
||||
|
||||
_log = Logger;
|
||||
settings!: ObsidianLiveSyncSettings;
|
||||
localDatabase!: LiveSyncLocalDB;
|
||||
replicator!: LiveSyncAbstractReplicator;
|
||||
|
||||
_suspended = false;
|
||||
get suspended() {
|
||||
return this._suspended || !this.settings?.isConfigured;
|
||||
}
|
||||
set suspended(value: boolean) {
|
||||
this._suspended = value;
|
||||
}
|
||||
get shouldBatchSave() {
|
||||
return this.settings?.batchSave && this.settings?.liveSync != true;
|
||||
}
|
||||
get batchSaveMinimumDelay(): number {
|
||||
return this.settings?.batchSaveMinimumDelay ?? DEFAULT_SETTINGS.batchSaveMinimumDelay
|
||||
}
|
||||
get batchSaveMaximumDelay(): number {
|
||||
return this.settings?.batchSaveMaximumDelay ?? DEFAULT_SETTINGS.batchSaveMaximumDelay
|
||||
}
|
||||
deviceAndVaultName = "";
|
||||
isReady = false;
|
||||
packageVersion = "";
|
||||
manifestVersion = "";
|
||||
|
||||
export default class ObsidianLiveSyncPlugin
|
||||
extends Plugin
|
||||
implements
|
||||
LiveSyncLocalDBEnv,
|
||||
LiveSyncReplicatorEnv,
|
||||
LiveSyncJournalReplicatorEnv,
|
||||
LiveSyncCouchDBReplicatorEnv,
|
||||
HasSettings<ObsidianLiveSyncSettings>
|
||||
{
|
||||
// --> Module System
|
||||
getAddOn<T extends LiveSyncCommands>(cls: string) {
|
||||
for (const addon of this.addOns) {
|
||||
@@ -120,9 +120,16 @@ export default class ObsidianLiveSyncPlugin extends Plugin implements LiveSyncLo
|
||||
}
|
||||
|
||||
// Keep order to display the dialogue in order.
|
||||
addOns = [new ConfigSync(this), new HiddenFileSync(this)] as LiveSyncCommands[];
|
||||
addOns = [
|
||||
new ConfigSync(this),
|
||||
new HiddenFileSync(this),
|
||||
new LocalDatabaseMaintenance(this),
|
||||
new P2PReplicator(this),
|
||||
] as LiveSyncCommands[];
|
||||
|
||||
modules = [
|
||||
new ModuleLiveSyncMain(this),
|
||||
new ModuleExtraSyncObsidian(this, this),
|
||||
// Only on Obsidian
|
||||
new ModuleDatabaseFileAccess(this),
|
||||
// Common
|
||||
@@ -160,54 +167,89 @@ export default class ObsidianLiveSyncPlugin extends Plugin implements LiveSyncLo
|
||||
// Common modules
|
||||
// Note: Platform-dependent functions are not entirely dependent on the core only, as they are from platform-dependent modules. Stubbing is sometimes required.
|
||||
new ModuleCheckRemoteSize(this),
|
||||
// Test and Dev Modules
|
||||
// Test and Dev Modules
|
||||
new ModuleDev(this, this),
|
||||
new ModuleReplicateTest(this, this),
|
||||
new ModuleIntegratedTest(this, this),
|
||||
] as (IObsidianModule | AbstractModule)[];
|
||||
injected = injectModules(this, [...this.modules, ...this.addOns] as ICoreModule[]);
|
||||
// <-- Module System
|
||||
|
||||
$$addLog(message: any, level: LOG_LEVEL = LOG_LEVEL_INFO, key = ""): void { throwShouldBeOverridden() }
|
||||
$$isSuspended(): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$setSuspended(value: boolean): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$isDatabaseReady(): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$getDeviceAndVaultName(): string {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$setDeviceAndVaultName(name: string): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$addLog(message: any, level: LOG_LEVEL = LOG_LEVEL_INFO, key = ""): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$isReady(): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$markIsReady(): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$resetIsReady(): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
// Following are plugged by the modules.
|
||||
|
||||
settings!: ObsidianLiveSyncSettings;
|
||||
localDatabase!: LiveSyncLocalDB;
|
||||
simpleStore!: SimpleStore<CheckPointInfo>;
|
||||
replicator!: LiveSyncAbstractReplicator;
|
||||
confirm!: Confirm;
|
||||
storageAccess!: StorageAccess;
|
||||
databaseFileAccess!: DatabaseFileAccess;
|
||||
fileHandler!: ModuleFileHandler;
|
||||
rebuilder!: Rebuilder;
|
||||
|
||||
// implementing interfaces
|
||||
kvDB!: KeyValueDatabase;
|
||||
last_successful_post = false;
|
||||
getDatabase(): PouchDB.Database<EntryDoc> {
|
||||
return this.localDatabase.localDatabase;
|
||||
}
|
||||
getSettings(): ObsidianLiveSyncSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
totalFileEventCount = 0;
|
||||
$$markFileListPossiblyChanged(): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$customFetchHandler(): ObsHttpHandler {
|
||||
throw new Error("This function should be overridden by the module.");
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
customFetchHandler() {
|
||||
return this.$$customFetchHandler();
|
||||
$$getLastPostFailedBySize(): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
getLastPostFailedBySize() {
|
||||
return !this.last_successful_post;
|
||||
$$isStorageInsensitive(): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
|
||||
|
||||
getDatabase(): PouchDB.Database<EntryDoc> { return this.localDatabase.localDatabase; }
|
||||
getSettings(): ObsidianLiveSyncSettings { return this.settings; }
|
||||
getIsMobile(): boolean { return this.isMobile; }
|
||||
|
||||
$$isStorageInsensitive(): boolean { throwShouldBeOverridden() }
|
||||
|
||||
get shouldCheckCaseInsensitive() {
|
||||
if (this.$$isStorageInsensitive()) return false;
|
||||
return !this.settings.handleFilenameCaseSensitive;
|
||||
$$shouldCheckCaseInsensitive(): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
_unloaded = false;
|
||||
$$isUnloaded(): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
requestCount = reactiveSource(0);
|
||||
responseCount = reactiveSource(0);
|
||||
@@ -222,9 +264,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin implements LiveSyncLo
|
||||
processingFileEventCount = reactiveSource(0);
|
||||
|
||||
_totalProcessingCount?: ReactiveValue<number>;
|
||||
get isReloadingScheduled() {
|
||||
return this._totalProcessingCount !== undefined;
|
||||
}
|
||||
|
||||
replicationStat = reactiveSource({
|
||||
sent: 0,
|
||||
@@ -233,73 +272,102 @@ export default class ObsidianLiveSyncPlugin extends Plugin implements LiveSyncLo
|
||||
maxPushSeq: 0,
|
||||
lastSyncPullSeq: 0,
|
||||
lastSyncPushSeq: 0,
|
||||
syncStatus: "CLOSED" as DatabaseConnectingStatus
|
||||
syncStatus: "CLOSED" as DatabaseConnectingStatus,
|
||||
});
|
||||
|
||||
get isMobile() { return this.$$isMobile(); }
|
||||
|
||||
|
||||
// Plug-in's overrideable functions
|
||||
onload() { void this.onLiveSyncLoad(); }
|
||||
async saveSettings() { await this.$$saveSettingData(); }
|
||||
onunload() { return void this.onLiveSyncUnload(); }
|
||||
// <-- Plug-in's overrideable functions
|
||||
|
||||
$$connectRemoteCouchDB(uri: string, auth: {
|
||||
username: string;
|
||||
password: string
|
||||
}, disableRequestURI: boolean, passphrase: string | false, useDynamicIterationCount: boolean, performSetup: boolean, skipInfo: boolean, compression: boolean): Promise<string | {
|
||||
db: PouchDB.Database<EntryDoc>;
|
||||
info: PouchDB.Core.DatabaseInfo
|
||||
}> {
|
||||
$$isReloadingScheduled(): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$getReplicator(): LiveSyncAbstractReplicator {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$connectRemoteCouchDB(
|
||||
uri: string,
|
||||
auth: CouchDBCredentials,
|
||||
disableRequestURI: boolean,
|
||||
passphrase: string | false,
|
||||
useDynamicIterationCount: boolean,
|
||||
performSetup: boolean,
|
||||
skipInfo: boolean,
|
||||
compression: boolean,
|
||||
customHeaders: Record<string, string>
|
||||
): Promise<
|
||||
| string
|
||||
| {
|
||||
db: PouchDB.Database<EntryDoc>;
|
||||
info: PouchDB.Core.DatabaseInfo;
|
||||
}
|
||||
> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$isMobile(): boolean { throwShouldBeOverridden(); }
|
||||
$$vaultName(): string { throwShouldBeOverridden(); }
|
||||
$$isMobile(): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$vaultName(): string {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
// --> Path
|
||||
|
||||
$$getActiveFilePath(): FilePathWithPrefix | undefined { throwShouldBeOverridden(); }
|
||||
$$getActiveFilePath(): FilePathWithPrefix | undefined {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
// <-- Path
|
||||
|
||||
// --> Path conversion
|
||||
$$id2path(id: DocumentID, entry?: EntryHasPath, stripPrefix?: boolean): FilePathWithPrefix { throwShouldBeOverridden(); }
|
||||
$$id2path(id: DocumentID, entry?: EntryHasPath, stripPrefix?: boolean): FilePathWithPrefix {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise<DocumentID> { throwShouldBeOverridden(); }
|
||||
$$path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise<DocumentID> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
// <!-- Path conversion
|
||||
|
||||
// --> Database
|
||||
$$createPouchDBInstance<T extends object>(name?: string, options?: PouchDB.Configuration.DatabaseConfiguration): PouchDB.Database<T> { throwShouldBeOverridden(); }
|
||||
$$createPouchDBInstance<T extends object>(
|
||||
name?: string,
|
||||
options?: PouchDB.Configuration.DatabaseConfiguration
|
||||
): PouchDB.Database<T> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$allOnDBUnload(db: LiveSyncLocalDB): void { return; }
|
||||
$allOnDBClose(db: LiveSyncLocalDB): void { return; }
|
||||
$allOnDBUnload(db: LiveSyncLocalDB): void {
|
||||
return;
|
||||
}
|
||||
$allOnDBClose(db: LiveSyncLocalDB): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// <!-- Database
|
||||
|
||||
$anyNewReplicator(settingOverride: Partial<ObsidianLiveSyncSettings> = {}): Promise<LiveSyncAbstractReplicator> { throwShouldBeOverridden(); }
|
||||
$anyNewReplicator(settingOverride: Partial<ObsidianLiveSyncSettings> = {}): Promise<LiveSyncAbstractReplicator> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$everyOnInitializeDatabase(db: LiveSyncLocalDB): Promise<boolean> { return InterceptiveEvery; }
|
||||
$everyOnInitializeDatabase(db: LiveSyncLocalDB): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
|
||||
$everyOnResetDatabase(db: LiveSyncLocalDB): Promise<boolean> { return InterceptiveEvery; }
|
||||
|
||||
getReplicator() {
|
||||
return this.replicator;
|
||||
$everyOnResetDatabase(db: LiveSyncLocalDB): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
|
||||
// end interfaces
|
||||
|
||||
$$getVaultName(): string { throwShouldBeOverridden(); }
|
||||
$$getVaultName(): string {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
simpleStore!: SimpleStore<CheckPointInfo>
|
||||
|
||||
$$getSimpleStore<T>(kind: string): SimpleStore<T> { throwShouldBeOverridden(); }
|
||||
$$getSimpleStore<T>(kind: string): SimpleStore<T> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
// trench!: Trench;
|
||||
|
||||
|
||||
// --> Events
|
||||
|
||||
/*
|
||||
@@ -336,188 +404,118 @@ export default class ObsidianLiveSyncPlugin extends Plugin implements LiveSyncLo
|
||||
6. localDatabase.onunload
|
||||
7. replicator.closeReplication
|
||||
8. localDatabase.close
|
||||
9. (event) EVENT_PLATFORM_UNLOADED
|
||||
|
||||
*/
|
||||
|
||||
|
||||
$everyOnLayoutReady(): Promise<boolean> { return InterceptiveEvery }
|
||||
$everyOnFirstInitialize(): Promise<boolean> { return InterceptiveEvery }
|
||||
$everyOnLayoutReady(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
$everyOnFirstInitialize(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
|
||||
// Some Module should call this function to start the plugin.
|
||||
async onLiveSyncReady() {
|
||||
if (!await this.$everyOnLayoutReady()) return;
|
||||
eventHub.emitEvent(EVENT_LAYOUT_READY);
|
||||
if (this.settings.suspendFileWatching) {
|
||||
Logger("'Suspend file watching' turned on. Are you sure this is what you intended? Every modification on the vault will be ignored.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
if (this.settings.suspendParseReplicationResult) {
|
||||
Logger("'Suspend database reflecting' turned on. Are you sure this is what you intended? Every replicated change will be postponed until disabling this option.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
const isInitialized = await this.$$initializeDatabase(false, false);
|
||||
if (!isInitialized) {
|
||||
//TODO:stop all sync.
|
||||
return false;
|
||||
}
|
||||
if (!await this.$everyOnFirstInitialize()) return;
|
||||
await this.realizeSettingSyncMode();
|
||||
fireAndForget(async () => {
|
||||
Logger(`Additional safety scan..`, LOG_LEVEL_VERBOSE);
|
||||
if (!await this.$allScanStat()) {
|
||||
Logger(`Additional safety scan has been failed on some module`, LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
Logger(`Additional safety scan done`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
});
|
||||
$$onLiveSyncReady(): Promise<false | undefined> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
wireUpEvents() {
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (evt: CustomEvent<ObsidianLiveSyncSettings>) => {
|
||||
const settings = evt.detail;
|
||||
this.localDatabase.settings = settings;
|
||||
setLang(settings.displayLanguage);
|
||||
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||
});
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (evt: CustomEvent<ObsidianLiveSyncSettings>) => {
|
||||
fireAndForget(() => this.realizeSettingSyncMode());
|
||||
})
|
||||
$$wireUpEvents(): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$onLiveSyncLoad(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
|
||||
async onLiveSyncLoad() {
|
||||
this.wireUpEvents();
|
||||
// debugger;
|
||||
eventHub.emitEvent(EVENT_PLUGIN_LOADED, this);
|
||||
Logger("loading plugin");
|
||||
if (!await this.$everyOnloadStart()) {
|
||||
Logger("Plugin initialising has been cancelled by some module", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
// this.addUIs();
|
||||
//@ts-ignore
|
||||
const manifestVersion: string = MANIFEST_VERSION || "0.0.0";
|
||||
//@ts-ignore
|
||||
const packageVersion: string = PACKAGE_VERSION || "0.0.0";
|
||||
|
||||
this.manifestVersion = manifestVersion;
|
||||
this.packageVersion = packageVersion;
|
||||
|
||||
Logger($f`Self-hosted LiveSync${" v"}${manifestVersion} ${packageVersion}`);
|
||||
await this.$$loadSettings();
|
||||
if (!await this.$everyOnloadAfterLoadSettings()) {
|
||||
Logger("Plugin initialising has been cancelled by some module", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const lsKey = "obsidian-live-sync-ver" + this.$$getVaultName();
|
||||
const last_version = localStorage.getItem(lsKey);
|
||||
|
||||
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
|
||||
if (lastVersion > this.settings.lastReadUpdates && this.settings.isConfigured) {
|
||||
Logger($f`Self-hosted LiveSync has undergone a major upgrade. Please open the setting dialog, and check the information pane.`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
if (this.isMobile) {
|
||||
this.settings.disableRequestURI = true;
|
||||
}
|
||||
if (last_version && Number(last_version) < VER) {
|
||||
this.settings.liveSync = false;
|
||||
this.settings.syncOnSave = false;
|
||||
this.settings.syncOnEditorSave = false;
|
||||
this.settings.syncOnStart = false;
|
||||
this.settings.syncOnFileOpen = false;
|
||||
this.settings.syncAfterMerge = false;
|
||||
this.settings.periodicReplication = false;
|
||||
this.settings.versionUpFlash = $f`Self-hosted LiveSync has been upgraded and some behaviors have changed incompatibly. All automatic synchronization is now disabled temporary. Ensure that other devices are also upgraded, and enable synchronization again.`;
|
||||
await this.saveSettings();
|
||||
}
|
||||
localStorage.setItem(lsKey, `${VER}`);
|
||||
await this.$$openDatabase();
|
||||
this.realizeSettingSyncMode = this.realizeSettingSyncMode.bind(this);
|
||||
// this.$$parseReplicationResult = this.$$parseReplicationResult.bind(this);
|
||||
// this.$$replicate = this.$$replicate.bind(this);
|
||||
this.onLiveSyncReady = this.onLiveSyncReady.bind(this);
|
||||
await this.$everyOnload();
|
||||
await Promise.all(this.addOns.map(e => e.onload()));
|
||||
}
|
||||
|
||||
async onLiveSyncUnload() {
|
||||
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
await this.$allStartOnUnload();
|
||||
cancelAllPeriodicTask();
|
||||
cancelAllTasks();
|
||||
stopAllRunningProcessors();
|
||||
await this.$allOnUnload();
|
||||
this._unloaded = true;
|
||||
for (const addOn of this.addOns) {
|
||||
addOn.onunload();
|
||||
}
|
||||
if (this.localDatabase != null) {
|
||||
this.localDatabase.onunload();
|
||||
if (this.replicator) {
|
||||
this.replicator?.closeReplication();
|
||||
}
|
||||
await this.localDatabase.close();
|
||||
}
|
||||
Logger($f`unloading plugin`);
|
||||
$$onLiveSyncUnload(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$allScanStat(): Promise<boolean> {
|
||||
return InterceptiveAll;
|
||||
}
|
||||
$everyOnloadStart(): Promise<boolean> { return InterceptiveEvery; }
|
||||
|
||||
$everyOnloadAfterLoadSettings(): Promise<boolean> { return InterceptiveEvery; }
|
||||
|
||||
$everyOnload(): Promise<boolean> { return InterceptiveEvery; }
|
||||
|
||||
$anyHandlerProcessesFileEvent(item: FileEventItem): Promise<boolean | undefined> { return InterceptiveAny; }
|
||||
|
||||
|
||||
$allStartOnUnload(): Promise<boolean> { return InterceptiveAll }
|
||||
$allOnUnload(): Promise<boolean> { return InterceptiveAll; }
|
||||
|
||||
|
||||
$$openDatabase(): Promise<boolean> { throwShouldBeOverridden() }
|
||||
|
||||
async realizeSettingSyncMode() {
|
||||
await this.$everyBeforeSuspendProcess();
|
||||
await this.$everyBeforeRealizeSetting();
|
||||
this.localDatabase.refreshSettings();
|
||||
await this.$everyCommitPendingFileEvent();
|
||||
await this.$everyRealizeSettingSyncMode();
|
||||
// disable all sync temporary.
|
||||
if (this.suspended) return;
|
||||
await this.$everyOnResumeProcess();
|
||||
await this.$everyAfterResumeProcess();
|
||||
await this.$everyAfterRealizeSetting();
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
$$performRestart() { throwShouldBeOverridden(); }
|
||||
|
||||
$$clearUsedPassphrase(): void { throwShouldBeOverridden() }
|
||||
$$loadSettings(): Promise<void> { throwShouldBeOverridden() }
|
||||
$everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
|
||||
$$saveDeviceAndVaultName(): void { throwShouldBeOverridden(); }
|
||||
$everyOnload(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
|
||||
$$saveSettingData(): Promise<void> { throwShouldBeOverridden() }
|
||||
$anyHandlerProcessesFileEvent(item: FileEventItem): Promise<boolean | undefined> {
|
||||
return InterceptiveAny;
|
||||
}
|
||||
|
||||
$anyProcessOptionalFileEvent(path: FilePath): Promise<boolean | undefined> { return InterceptiveAny; }
|
||||
$allStartOnUnload(): Promise<boolean> {
|
||||
return InterceptiveAll;
|
||||
}
|
||||
$allOnUnload(): Promise<boolean> {
|
||||
return InterceptiveAll;
|
||||
}
|
||||
|
||||
$everyCommitPendingFileEvent(): Promise<boolean> { return InterceptiveEvery }
|
||||
$$openDatabase(): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
// ->
|
||||
$anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | undefined | "newer"> { return InterceptiveAny; }
|
||||
$$realizeSettingSyncMode(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$performRestart() {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> { throwShouldBeOverridden() }
|
||||
$$clearUsedPassphrase(): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$loadSettings(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$queueConflictCheck(file: FilePathWithPrefix): Promise<void> { throwShouldBeOverridden() }
|
||||
$$saveDeviceAndVaultName(): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$waitForAllConflictProcessed(): Promise<boolean> { throwShouldBeOverridden() }
|
||||
$$saveSettingData(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$anyProcessOptionalFileEvent(path: FilePath): Promise<boolean | undefined> {
|
||||
return InterceptiveAny;
|
||||
}
|
||||
|
||||
$everyCommitPendingFileEvent(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
|
||||
// ->
|
||||
$anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | undefined | "newer"> {
|
||||
return InterceptiveAny;
|
||||
}
|
||||
|
||||
$$queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$queueConflictCheck(file: FilePathWithPrefix): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$waitForAllConflictProcessed(): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
//<-- Conflict Check
|
||||
|
||||
$anyProcessOptionalSyncFiles(doc: LoadedEntry): Promise<boolean | undefined> { return InterceptiveAny; }
|
||||
|
||||
$anyProcessReplicatedDoc(doc: MetaEntry): Promise<boolean | undefined> { return InterceptiveAny; }
|
||||
$anyProcessOptionalSyncFiles(doc: LoadedEntry): Promise<boolean | undefined> {
|
||||
return InterceptiveAny;
|
||||
}
|
||||
|
||||
$anyProcessReplicatedDoc(doc: MetaEntry): Promise<boolean | undefined> {
|
||||
return InterceptiveAny;
|
||||
}
|
||||
|
||||
//---> Sync
|
||||
$$parseReplicationResult(docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>): void {
|
||||
@@ -546,86 +544,177 @@ export default class ObsidianLiveSyncPlugin extends Plugin implements LiveSyncLo
|
||||
$everyAfterResumeProcess(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
$$checkAndAskResolvingMismatchedTweaks(preferred: Partial<TweakValues>): Promise<[TweakValues | boolean, boolean]> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$askResolvingMismatchedTweaks(preferredSource: TweakValues): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$checkAndAskUseRemoteConfiguration(
|
||||
settings: RemoteDBSettings
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$askUseRemoteConfiguration(
|
||||
trialSetting: RemoteDBSettings,
|
||||
preferred: TweakValues
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$everyBeforeReplicate(showMessage: boolean): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
$$replicate(showMessage: boolean = false): Promise<boolean | void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$replicateByEvent(showMessage: boolean = false): Promise<boolean | void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> { throwShouldBeOverridden(); }
|
||||
$everyBeforeReplicate(showMessage: boolean): Promise<boolean> { return InterceptiveEvery; }
|
||||
$$replicate(showMessage: boolean = false): Promise<boolean | void> { throwShouldBeOverridden() }
|
||||
$everyOnDatabaseInitialized(showingNotice: boolean): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$everyOnDatabaseInitialized(showingNotice: boolean): Promise<boolean> { throwShouldBeOverridden() }
|
||||
$$initializeDatabase(showingNotice: boolean = false, reopenDatabase = true): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$initializeDatabase(showingNotice: boolean = false, reopenDatabase = true): Promise<boolean> { throwShouldBeOverridden() }
|
||||
$anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||
return InterceptiveAny;
|
||||
}
|
||||
|
||||
$anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> { return InterceptiveAny; }
|
||||
|
||||
$$replicateAllToServer(showingNotice: boolean = false): Promise<boolean> { throwShouldBeOverridden() }
|
||||
$$replicateAllFromServer(showingNotice: boolean = false): Promise<boolean> { throwShouldBeOverridden() }
|
||||
$$replicateAllToServer(
|
||||
showingNotice: boolean = false,
|
||||
sendChunksInBulkDisabled: boolean = false
|
||||
): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$replicateAllFromServer(showingNotice: boolean = false): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
// Remote Governing
|
||||
$$markRemoteLocked(lockByClean: boolean = false): Promise<void> { throwShouldBeOverridden() }
|
||||
$$markRemoteLocked(lockByClean: boolean = false): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$markRemoteUnlocked(): Promise<void> { throwShouldBeOverridden() }
|
||||
$$markRemoteUnlocked(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$markRemoteResolved(): Promise<void> { throwShouldBeOverridden() }
|
||||
$$markRemoteResolved(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
// <-- Remote Governing
|
||||
|
||||
|
||||
$$isFileSizeExceeded(size: number): boolean { throwShouldBeOverridden() }
|
||||
|
||||
$$performFullScan(showingNotice?: boolean): Promise<void> { throwShouldBeOverridden(); }
|
||||
|
||||
$anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise<boolean | undefined> { return InterceptiveAny; }
|
||||
$$resolveConflictByDeletingRev(path: FilePathWithPrefix, deleteRevision: string, subTitle = ""): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> { throwShouldBeOverridden(); }
|
||||
$$resolveConflict(filename: FilePathWithPrefix): Promise<void> { throwShouldBeOverridden(); }
|
||||
$anyResolveConflictByNewest(filename: FilePathWithPrefix): Promise<boolean> { throwShouldBeOverridden(); }
|
||||
|
||||
$$waitForReplicationOnce(): Promise<boolean | void> { throwShouldBeOverridden(); }
|
||||
|
||||
$$resetLocalDatabase(): Promise<void> { throwShouldBeOverridden(); }
|
||||
|
||||
$$tryResetRemoteDatabase(): Promise<void> { throwShouldBeOverridden(); }
|
||||
|
||||
$$tryCreateRemoteDatabase(): Promise<void> { throwShouldBeOverridden(); }
|
||||
|
||||
$$isIgnoredByIgnoreFiles(file: string | UXFileInfoStub): Promise<boolean> { throwShouldBeOverridden(); }
|
||||
|
||||
$$isTargetFile(file: string | UXFileInfoStub, keepFileCheckList = false): Promise<boolean> { throwShouldBeOverridden(); }
|
||||
|
||||
|
||||
|
||||
$$askReload(message?: string) { throwShouldBeOverridden(); }
|
||||
$$scheduleAppReload() { throwShouldBeOverridden(); }
|
||||
|
||||
//--- Setup
|
||||
$allSuspendAllSync(): Promise<boolean> { return InterceptiveAll; }
|
||||
$allSuspendExtraSync(): Promise<boolean> { return InterceptiveAll; }
|
||||
|
||||
$allAskUsingOptionalSyncFeature(opt: {
|
||||
enableFetch?: boolean,
|
||||
enableOverwrite?: boolean
|
||||
}): Promise<boolean> {
|
||||
$$isFileSizeExceeded(size: number): boolean {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$anyConfigureOptionalSyncFeature(mode: string): Promise<void> { throwShouldBeOverridden(); }
|
||||
|
||||
$$showView(viewType: string): Promise<void> { throwShouldBeOverridden(); }
|
||||
$$performFullScan(showingNotice?: boolean): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$anyResolveConflictByUI(
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
): Promise<boolean | undefined> {
|
||||
return InterceptiveAny;
|
||||
}
|
||||
$$resolveConflictByDeletingRev(
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = ""
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$resolveConflict(filename: FilePathWithPrefix): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$anyResolveConflictByNewest(filename: FilePathWithPrefix): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$resetLocalDatabase(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$tryResetRemoteDatabase(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$tryCreateRemoteDatabase(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$isIgnoredByIgnoreFiles(file: string | UXFileInfoStub): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$isTargetFile(file: string | UXFileInfoStub, keepFileCheckList = false): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$askReload(message?: string) {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$scheduleAppReload() {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
//--- Setup
|
||||
$allSuspendAllSync(): Promise<boolean> {
|
||||
return InterceptiveAll;
|
||||
}
|
||||
$allSuspendExtraSync(): Promise<boolean> {
|
||||
return InterceptiveAll;
|
||||
}
|
||||
|
||||
$allAskUsingOptionalSyncFeature(opt: { enableFetch?: boolean; enableOverwrite?: boolean }): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$anyConfigureOptionalSyncFeature(mode: string): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$showView(viewType: string): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
// For Development: Ensure reliability MORE AND MORE. May the this plug-in helps all of us.
|
||||
$everyModuleTest(): Promise<boolean> { return InterceptiveEvery; }
|
||||
$everyModuleTestMultiDevice(): Promise<boolean> { return InterceptiveEvery; }
|
||||
$$addTestResult(name: string, key: string, result: boolean, summary?: string, message?: string): void { throwShouldBeOverridden(); }
|
||||
$everyModuleTest(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
$everyModuleTestMultiDevice(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
$$addTestResult(name: string, key: string, result: boolean, summary?: string, message?: string): void {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
_isMainReady(): boolean { return this.isReady; }
|
||||
_isMainSuspended(): boolean { return this.suspended; }
|
||||
_isThisModuleEnabled(): boolean { return true; }
|
||||
_isDatabaseReady(): boolean { return this.localDatabase.isReady; }
|
||||
_isThisModuleEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
$anyGetAppId(): Promise<string | undefined> { return InterceptiveAny; }
|
||||
$anyGetAppId(): Promise<string | undefined> {
|
||||
return InterceptiveAny;
|
||||
}
|
||||
|
||||
// Plug-in's overrideable functions
|
||||
onload() {
|
||||
void this.$$onLiveSyncLoad();
|
||||
}
|
||||
async saveSettings() {
|
||||
await this.$$saveSettingData();
|
||||
}
|
||||
onunload() {
|
||||
return void this.$$onLiveSyncUnload();
|
||||
}
|
||||
// <-- Plug-in's overrideable functions
|
||||
}
|
||||
|
||||
|
||||
// For now,
|
||||
export type LiveSyncCore = ObsidianLiveSyncPlugin;
|
||||
export type LiveSyncCore = ObsidianLiveSyncPlugin;
|
||||
|
||||
@@ -3,8 +3,14 @@ import type { LOG_LEVEL } from "../lib/src/common/types";
|
||||
import type { LiveSyncCore } from "../main";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import type { IObsidianModule } from "./AbstractObsidianModule.ts";
|
||||
import type { ICoreModuleBase, AllInjectableProps, AllExecuteProps, EveryExecuteProps, AnyExecuteProps, ICoreModule } from "./ModuleTypes";
|
||||
|
||||
import type {
|
||||
ICoreModuleBase,
|
||||
AllInjectableProps,
|
||||
AllExecuteProps,
|
||||
EveryExecuteProps,
|
||||
AnyExecuteProps,
|
||||
ICoreModule,
|
||||
} from "./ModuleTypes";
|
||||
|
||||
function isOverridableKey(key: string): key is keyof ICoreModuleBase {
|
||||
return key.startsWith("$");
|
||||
@@ -14,7 +20,6 @@ function isInjectableKey(key: string): key is keyof AllInjectableProps {
|
||||
return key.startsWith("$$");
|
||||
}
|
||||
|
||||
|
||||
function isAllExecuteKey(key: string): key is keyof AllExecuteProps {
|
||||
return key.startsWith("$all");
|
||||
}
|
||||
@@ -35,15 +40,17 @@ function isAnyExecuteKey(key: string): key is keyof AnyExecuteProps {
|
||||
* All of above performed on injectModules function.
|
||||
*/
|
||||
export function injectModules<T extends ICoreModule>(target: T, modules: ICoreModule[]) {
|
||||
const allKeys = unique([...Object.keys(Object.getOwnPropertyDescriptors(target)),
|
||||
...Object.keys(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(target)))]).filter(e => e.startsWith("$")) as (keyof ICoreModule)[];
|
||||
const allKeys = unique([
|
||||
...Object.keys(Object.getOwnPropertyDescriptors(target)),
|
||||
...Object.keys(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(target))),
|
||||
]).filter((e) => e.startsWith("$")) as (keyof ICoreModule)[];
|
||||
const moduleMap = new Map<string, IObsidianModule[]>();
|
||||
for (const module of modules) {
|
||||
for (const key of allKeys) {
|
||||
if (isOverridableKey(key)) {
|
||||
if (key in module) {
|
||||
const list = moduleMap.get(key) || [];
|
||||
if (typeof module[key] === 'function') {
|
||||
if (typeof module[key] === "function") {
|
||||
module[key] = module[key].bind(module) as any;
|
||||
}
|
||||
list.push(module);
|
||||
@@ -74,7 +81,7 @@ export function injectModules<T extends ICoreModule>(target: T, modules: ICoreMo
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
for (const module of modules) {
|
||||
Logger(`[${module.constructor.name}]: Injected (All) ${key} `, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
@@ -94,7 +101,7 @@ export function injectModules<T extends ICoreModule>(target: T, modules: ICoreMo
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
for (const module of modules) {
|
||||
Logger(`[${module.constructor.name}]: Injected (Every) ${key} `, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
@@ -115,7 +122,7 @@ export function injectModules<T extends ICoreModule>(target: T, modules: ICoreMo
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
for (const module of modules) {
|
||||
Logger(`[${module.constructor.name}]: Injected (Any) ${key} `, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
@@ -127,7 +134,6 @@ export function injectModules<T extends ICoreModule>(target: T, modules: ICoreMo
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export abstract class AbstractModule {
|
||||
_log = (msg: any, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string) => {
|
||||
if (typeof msg === "string" && level !== LOG_LEVEL_NOTICE) {
|
||||
@@ -173,11 +179,10 @@ export abstract class AbstractModule {
|
||||
return this.testFail(`${key} failed: ${ret}`);
|
||||
}
|
||||
this.addTestResult(key, true, "");
|
||||
}
|
||||
catch (ex: any) {
|
||||
} catch (ex: any) {
|
||||
this.addTestResult(key, false, "Failed by Exception", ex.toString());
|
||||
return this.testFail(`${key} failed: ${ex}`);
|
||||
}
|
||||
return this.testDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,12 @@ import type ObsidianLiveSyncPlugin from "../main";
|
||||
import { AbstractModule } from "./AbstractModule.ts";
|
||||
import type { ChainableExecuteFunction, OverridableFunctionsKeys } from "./ModuleTypes";
|
||||
|
||||
|
||||
export type IObsidianModuleBase = OverridableFunctionsKeys<ObsidianLiveSyncPlugin>;
|
||||
export type IObsidianModule = Prettify<Partial<IObsidianModuleBase>>
|
||||
export type IObsidianModule = Prettify<Partial<IObsidianModuleBase>>;
|
||||
export type ModuleKeys = keyof IObsidianModule;
|
||||
export type ChainableModuleProps = ChainableExecuteFunction<ObsidianLiveSyncPlugin>;
|
||||
|
||||
|
||||
export abstract class AbstractObsidianModule extends AbstractModule {
|
||||
|
||||
addCommand = this.plugin.addCommand.bind(this.plugin);
|
||||
registerView = this.plugin.registerView.bind(this.plugin);
|
||||
addRibbonIcon = this.plugin.addRibbonIcon.bind(this.plugin);
|
||||
@@ -31,25 +28,27 @@ export abstract class AbstractObsidianModule extends AbstractModule {
|
||||
return this.plugin.app;
|
||||
}
|
||||
|
||||
constructor(public plugin: ObsidianLiveSyncPlugin, public core: LiveSyncCore) {
|
||||
constructor(
|
||||
public plugin: ObsidianLiveSyncPlugin,
|
||||
public core: LiveSyncCore
|
||||
) {
|
||||
super(core);
|
||||
}
|
||||
|
||||
saveSettings = this.plugin.saveSettings.bind(this.plugin);
|
||||
|
||||
|
||||
_isMainReady() {
|
||||
return this.core._isMainReady();
|
||||
return this.core.$$isReady();
|
||||
}
|
||||
_isMainSuspended() {
|
||||
return this.core._isMainSuspended();
|
||||
return this.core.$$isSuspended();
|
||||
}
|
||||
_isDatabaseReady() {
|
||||
return this.core._isDatabaseReady();
|
||||
return this.core.$$isDatabaseReady();
|
||||
}
|
||||
|
||||
//should be overridden
|
||||
_isThisModuleEnabled() {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,53 @@
|
||||
import type { Prettify } from "../lib/src/common/types";
|
||||
import type { LiveSyncCore } from "../main";
|
||||
|
||||
|
||||
export type OverridableFunctionsKeys<T> = {
|
||||
[K in keyof T as K extends `$${string}` ? K : never]: T[K];
|
||||
}
|
||||
};
|
||||
|
||||
export type ChainableExecuteFunction<T> = {
|
||||
[K in keyof T as K extends `$${string}` ? (T[K] extends (...args: any) => ChainableFunctionResult ? K : never) : never]: T[K];
|
||||
}
|
||||
[K in keyof T as K extends `$${string}`
|
||||
? T[K] extends (...args: any) => ChainableFunctionResult
|
||||
? K
|
||||
: never
|
||||
: never]: T[K];
|
||||
};
|
||||
|
||||
export type ICoreModuleBase = OverridableFunctionsKeys<LiveSyncCore>;
|
||||
export type ICoreModule = Prettify<Partial<ICoreModuleBase>>
|
||||
export type ICoreModule = Prettify<Partial<ICoreModuleBase>>;
|
||||
export type CoreModuleKeys = keyof ICoreModule;
|
||||
|
||||
export type ChainableFunctionResult =
|
||||
Promise<boolean | undefined | string>
|
||||
| Promise<boolean | undefined | string>
|
||||
| Promise<boolean | undefined>
|
||||
| Promise<boolean>
|
||||
| Promise<void>;
|
||||
export type ChainableFunctionResultOrAll = Promise<boolean | undefined | string | void>;
|
||||
|
||||
type AllExecuteFunction<T> = {
|
||||
[K in keyof T as K extends `$all${string}` ? (T[K] extends (...args: any[]) => ChainableFunctionResultOrAll ? K : never) : never]: T[K];
|
||||
}
|
||||
[K in keyof T as K extends `$all${string}`
|
||||
? T[K] extends (...args: any[]) => ChainableFunctionResultOrAll
|
||||
? K
|
||||
: never
|
||||
: never]: T[K];
|
||||
};
|
||||
type EveryExecuteFunction<T> = {
|
||||
[K in keyof T as K extends `$every${string}` ? (T[K] extends (...args: any[]) => ChainableFunctionResult ? K : never) : never]: T[K];
|
||||
}
|
||||
[K in keyof T as K extends `$every${string}`
|
||||
? T[K] extends (...args: any[]) => ChainableFunctionResult
|
||||
? K
|
||||
: never
|
||||
: never]: T[K];
|
||||
};
|
||||
type AnyExecuteFunction<T> = {
|
||||
[K in keyof T as K extends `$any${string}` ? (T[K] extends (...args: any[]) => ChainableFunctionResult ? K : never) : never]: T[K];
|
||||
}
|
||||
[K in keyof T as K extends `$any${string}`
|
||||
? T[K] extends (...args: any[]) => ChainableFunctionResult
|
||||
? K
|
||||
: never
|
||||
: never]: T[K];
|
||||
};
|
||||
type InjectableFunction<T> = {
|
||||
[K in keyof T as K extends `$$${string}` ? (T[K] extends (...args: any[]) => any ? K : never) : never]: T[K];
|
||||
}
|
||||
};
|
||||
export type AllExecuteProps = AllExecuteFunction<LiveSyncCore>;
|
||||
export type EveryExecuteProps = EveryExecuteFunction<LiveSyncCore>;
|
||||
export type AnyExecuteProps = AnyExecuteFunction<LiveSyncCore>;
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { EVENT_FILE_SAVED, eventHub } from "../../common/events";
|
||||
import { getPathFromUXFileInfo, isInternalMetadata, markChangesAreSame } from "../../common/utils";
|
||||
import type { UXFileInfoStub, FilePathWithPrefix, UXFileInfo, MetaEntry, LoadedEntry, FilePath, SavingEntry } from "../../lib/src/common/types";
|
||||
import {
|
||||
getDatabasePathFromUXFileInfo,
|
||||
getStoragePathFromUXFileInfo,
|
||||
isInternalMetadata,
|
||||
markChangesAreSame,
|
||||
} from "../../common/utils";
|
||||
import type {
|
||||
UXFileInfoStub,
|
||||
FilePathWithPrefix,
|
||||
UXFileInfo,
|
||||
MetaEntry,
|
||||
LoadedEntry,
|
||||
FilePath,
|
||||
SavingEntry,
|
||||
DocumentID,
|
||||
} from "../../lib/src/common/types";
|
||||
import type { DatabaseFileAccess } from "../interfaces/DatabaseFileAccess";
|
||||
import { type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { isPlainText, shouldBeIgnored } from "../../lib/src/string_and_binary/path";
|
||||
import { createBlob, createTextBlob, delay, determineTypeFromBlob, isDocContentSame, readContent } from "../../lib/src/common/utils";
|
||||
import { isPlainText, shouldBeIgnored, stripAllPrefixes } from "../../lib/src/string_and_binary/path";
|
||||
import {
|
||||
createBlob,
|
||||
createTextBlob,
|
||||
delay,
|
||||
determineTypeFromBlob,
|
||||
isDocContentSame,
|
||||
readContent,
|
||||
} from "../../lib/src/common/utils";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
|
||||
import { ICHeader } from "../../common/types.ts";
|
||||
|
||||
export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidianModule, DatabaseFileAccess {
|
||||
$everyOnload(): Promise<boolean> {
|
||||
@@ -18,7 +39,7 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
|
||||
async $everyModuleTest(): Promise<boolean> {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
const testString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec purus nec nunc"
|
||||
const testString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec purus nec nunc";
|
||||
// Before test, we need to delete completely.
|
||||
const conflicts = await this.getConflictedRevs("autoTest.md" as FilePathWithPrefix);
|
||||
for (const rev of conflicts) {
|
||||
@@ -27,16 +48,21 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
await this.delete("autoTest.md" as FilePathWithPrefix);
|
||||
// OK, begin!
|
||||
|
||||
await this._test("storeContent", async () => (await this.storeContent("autoTest.md" as FilePathWithPrefix, testString)));
|
||||
await this._test(
|
||||
"storeContent",
|
||||
async () => await this.storeContent("autoTest.md" as FilePathWithPrefix, testString)
|
||||
);
|
||||
// For test, we need to clear the caches.
|
||||
await this.localDatabase.hashCaches.clear();
|
||||
await this._test("readContent", async () => {
|
||||
const content = await this.fetch("autoTest.md" as FilePathWithPrefix);
|
||||
if (!content) return "File not found";
|
||||
if (content.deleted) return "File is deleted";
|
||||
return (await content.body.text() == testString) ? true : `Content is not same ${await content.body.text()}`;
|
||||
return (await content.body.text()) == testString
|
||||
? true
|
||||
: `Content is not same ${await content.body.text()}`;
|
||||
});
|
||||
await this._test("delete", async () => (await this.delete("autoTest.md" as FilePathWithPrefix)));
|
||||
await this._test("delete", async () => await this.delete("autoTest.md" as FilePathWithPrefix));
|
||||
await this._test("read deleted content", async () => {
|
||||
const content = await this.fetch("autoTest.md" as FilePathWithPrefix);
|
||||
if (!content) return true;
|
||||
@@ -48,8 +74,8 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
}
|
||||
|
||||
async checkIsTargetFile(file: UXFileInfoStub | FilePathWithPrefix): Promise<boolean> {
|
||||
const path = getPathFromUXFileInfo(file);
|
||||
if (!await this.core.$$isTargetFile(path)) {
|
||||
const path = getStoragePathFromUXFileInfo(file);
|
||||
if (!(await this.core.$$isTargetFile(path))) {
|
||||
this._log(`File is not target`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
@@ -61,10 +87,10 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
}
|
||||
|
||||
async delete(file: UXFileInfoStub | FilePathWithPrefix, rev?: string): Promise<boolean> {
|
||||
if (!await this.checkIsTargetFile(file)) {
|
||||
if (!(await this.checkIsTargetFile(file))) {
|
||||
return true;
|
||||
}
|
||||
const fullPath = getPathFromUXFileInfo(file);
|
||||
const fullPath = getDatabasePathFromUXFileInfo(file);
|
||||
try {
|
||||
this._log(`deleteDB By path:${fullPath}`);
|
||||
return await this.deleteFromDBbyPath(fullPath, rev);
|
||||
@@ -85,6 +111,7 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
async storeContent(path: FilePathWithPrefix, content: string): Promise<boolean> {
|
||||
const blob = createTextBlob(content);
|
||||
const bytes = (await blob.arrayBuffer()).byteLength;
|
||||
const isInternal = path.startsWith(".") ? true : undefined;
|
||||
const dummyUXFileInfo: UXFileInfo = {
|
||||
name: path.split("/").pop() as string,
|
||||
path: path,
|
||||
@@ -95,13 +122,19 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
type: "file",
|
||||
},
|
||||
body: blob,
|
||||
}
|
||||
isInternal,
|
||||
};
|
||||
return await this._store(dummyUXFileInfo, true, false, false);
|
||||
}
|
||||
|
||||
async _store(file: UXFileInfo, force: boolean = false, skipCheck?: boolean, onlyChunks?: boolean): Promise<boolean> {
|
||||
async _store(
|
||||
file: UXFileInfo,
|
||||
force: boolean = false,
|
||||
skipCheck?: boolean,
|
||||
onlyChunks?: boolean
|
||||
): Promise<boolean> {
|
||||
if (!skipCheck) {
|
||||
if (!await this.checkIsTargetFile(file)) {
|
||||
if (!(await this.checkIsTargetFile(file))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -109,18 +142,47 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
this._log("File seems bad", LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const path = getPathFromUXFileInfo(file);
|
||||
|
||||
// const path = getPathFromUXFileInfo(file);
|
||||
const isPlain = isPlainText(file.name);
|
||||
const possiblyLarge = !isPlain;
|
||||
const content = file.body;
|
||||
if (possiblyLarge) this._log(`Processing: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
|
||||
const datatype = determineTypeFromBlob(content);
|
||||
const fullPath = file.path;
|
||||
const id = await this.core.$$path2id(fullPath);
|
||||
const idPrefix = file.isInternal ? ICHeader : "";
|
||||
const fullPath = getStoragePathFromUXFileInfo(file);
|
||||
const fullPathOnDB = getDatabasePathFromUXFileInfo(file);
|
||||
|
||||
if (possiblyLarge) this._log(`Processing: ${fullPath}`, LOG_LEVEL_VERBOSE);
|
||||
|
||||
// if (isInternalMetadata(fullPath)) {
|
||||
// this._log(`Internal file: ${fullPath}`, LOG_LEVEL_VERBOSE);
|
||||
// return false;
|
||||
// }
|
||||
if (file.isInternal) {
|
||||
if (file.deleted) {
|
||||
file.stat = {
|
||||
size: 0,
|
||||
ctime: Date.now(),
|
||||
mtime: Date.now(),
|
||||
type: "file",
|
||||
};
|
||||
} else if (file.stat == undefined) {
|
||||
const stat = await this.core.storageAccess.statHidden(file.path);
|
||||
if (!stat) {
|
||||
// We stored actually deleted or not since here, so this is an unexpected case. we should raise an error.
|
||||
this._log(`Internal file not found: ${fullPath}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
file.stat = stat;
|
||||
}
|
||||
}
|
||||
|
||||
const idMain = await this.core.$$path2id(fullPath);
|
||||
|
||||
const id = (idPrefix + idMain) as DocumentID;
|
||||
const d: SavingEntry = {
|
||||
_id: id,
|
||||
path: file.path,
|
||||
path: fullPathOnDB,
|
||||
data: content,
|
||||
ctime: file.stat.ctime,
|
||||
mtime: file.stat.mtime,
|
||||
@@ -133,28 +195,38 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
//upsert should locked
|
||||
const msg = `STORAGE -> DB (${datatype}) `;
|
||||
const isNotChanged = await serialized("file-" + fullPath, async () => {
|
||||
if (force) {
|
||||
this._log(msg + "Force writing " + fullPath, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// Commented out temporarily: this checks that the file was made ourself.
|
||||
// if (this.core.storageAccess.recentlyTouched(file)) {
|
||||
// return true;
|
||||
// }
|
||||
try {
|
||||
const old = await this.localDatabase.getDBEntry(fullPath, undefined, false, true, false);
|
||||
const old = await this.localDatabase.getDBEntry(d.path, undefined, false, true, false);
|
||||
if (old !== false) {
|
||||
const oldData = { data: old.data, deleted: old._deleted || old.deleted };
|
||||
const newData = { data: d.data, deleted: d._deleted || d.deleted };
|
||||
if (oldData.deleted != newData.deleted) return false;
|
||||
if (!await isDocContentSame(old.data, newData.data)) return false;
|
||||
this._log(msg + "Skipped (not changed) " + fullPath + ((d._deleted || d.deleted) ? " (deleted)" : ""), LOG_LEVEL_VERBOSE);
|
||||
if (!(await isDocContentSame(old.data, newData.data))) return false;
|
||||
this._log(
|
||||
msg + "Skipped (not changed) " + fullPath + (d._deleted || d.deleted ? " (deleted)" : ""),
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
markChangesAreSame(old, d.mtime, old.mtime);
|
||||
return true;
|
||||
// d._rev = old._rev;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (force) {
|
||||
this._log(msg + "Error, Could not check the diff for the old one." + (force ? "force writing." : "") + fullPath + ((d._deleted || d.deleted) ? " (deleted)" : ""), LOG_LEVEL_VERBOSE);
|
||||
} else {
|
||||
this._log(msg + "Error, Could not check the diff for the old one." + fullPath + ((d._deleted || d.deleted) ? " (deleted)" : ""), LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this._log(
|
||||
msg +
|
||||
"Error, Could not check the diff for the old one." +
|
||||
(force ? "force writing." : "") +
|
||||
fullPath +
|
||||
(d._deleted || d.deleted ? " (deleted)" : ""),
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return !force;
|
||||
}
|
||||
@@ -173,10 +245,10 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
}
|
||||
|
||||
async getConflictedRevs(file: UXFileInfoStub | FilePathWithPrefix): Promise<string[]> {
|
||||
if (!await this.checkIsTargetFile(file)) {
|
||||
if (!(await this.checkIsTargetFile(file))) {
|
||||
return [];
|
||||
}
|
||||
const filename = getPathFromUXFileInfo(file);
|
||||
const filename = getDatabasePathFromUXFileInfo(file);
|
||||
const doc = await this.localDatabase.getDBEntryMeta(filename, { conflicts: true }, true);
|
||||
if (doc === false) {
|
||||
return [];
|
||||
@@ -184,9 +256,13 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
return doc._conflicts || [];
|
||||
}
|
||||
|
||||
async fetch(file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string, waitForReady?: boolean, skipCheck = false): Promise<UXFileInfo | false> {
|
||||
if (skipCheck && !await this.checkIsTargetFile(file)) {
|
||||
async fetch(
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady?: boolean,
|
||||
skipCheck = false
|
||||
): Promise<UXFileInfo | false> {
|
||||
if (skipCheck && !(await this.checkIsTargetFile(file))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -195,9 +271,10 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
return false;
|
||||
}
|
||||
const data = createBlob(readContent(entry));
|
||||
const path = stripAllPrefixes(entry.path);
|
||||
const fileInfo: UXFileInfo = {
|
||||
name: entry.path.split("/").pop() as string,
|
||||
path: entry.path,
|
||||
name: path.split("/").pop() as string,
|
||||
path: path,
|
||||
stat: {
|
||||
size: entry.size,
|
||||
ctime: entry.ctime,
|
||||
@@ -206,39 +283,49 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
},
|
||||
body: data,
|
||||
deleted: entry.deleted || entry._deleted,
|
||||
}
|
||||
};
|
||||
if (isInternalMetadata(entry.path)) {
|
||||
fileInfo.isInternal = true;
|
||||
}
|
||||
return fileInfo;
|
||||
}
|
||||
async fetchEntryMeta(file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string, skipCheck = false): Promise<MetaEntry | false> {
|
||||
const filename = getPathFromUXFileInfo(file);
|
||||
if (skipCheck && !await this.checkIsTargetFile(file)) {
|
||||
async fetchEntryMeta(
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
skipCheck = false
|
||||
): Promise<MetaEntry | false> {
|
||||
const dbFileName = getDatabasePathFromUXFileInfo(file);
|
||||
if (skipCheck && !(await this.checkIsTargetFile(file))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const doc = await this.localDatabase.getDBEntryMeta(
|
||||
filename, rev ? { rev: rev } : undefined, true);
|
||||
const doc = await this.localDatabase.getDBEntryMeta(dbFileName, rev ? { rev: rev } : undefined, true);
|
||||
if (doc === false) {
|
||||
return false;
|
||||
}
|
||||
return doc as MetaEntry;
|
||||
}
|
||||
async fetchEntryFromMeta(meta: MetaEntry, waitForReady: boolean = true, skipCheck = false): Promise<LoadedEntry | false> {
|
||||
if (skipCheck && !await this.checkIsTargetFile(meta.path)) {
|
||||
async fetchEntryFromMeta(
|
||||
meta: MetaEntry,
|
||||
waitForReady: boolean = true,
|
||||
skipCheck = false
|
||||
): Promise<LoadedEntry | false> {
|
||||
if (skipCheck && !(await this.checkIsTargetFile(meta.path))) {
|
||||
return false;
|
||||
}
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta(meta as LoadedEntry, undefined, false, waitForReady, true);
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta(meta as LoadedEntry, false, waitForReady);
|
||||
if (doc === false) {
|
||||
return false;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
async fetchEntry(file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string, waitForReady: boolean = true, skipCheck = false): Promise<LoadedEntry | false> {
|
||||
if (skipCheck && !await this.checkIsTargetFile(file)) {
|
||||
async fetchEntry(
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady: boolean = true,
|
||||
skipCheck = false
|
||||
): Promise<LoadedEntry | false> {
|
||||
if (skipCheck && !(await this.checkIsTargetFile(file))) {
|
||||
return false;
|
||||
}
|
||||
const entry = await this.fetchEntryMeta(file, rev, true);
|
||||
@@ -249,7 +336,7 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
return doc;
|
||||
}
|
||||
async deleteFromDBbyPath(fullPath: FilePath | FilePathWithPrefix, rev?: string): Promise<boolean> {
|
||||
if (!await this.checkIsTargetFile(fullPath)) {
|
||||
if (!(await this.checkIsTargetFile(fullPath))) {
|
||||
this._log(`storeFromStorage: File is not target: ${fullPath}`);
|
||||
return true;
|
||||
}
|
||||
@@ -258,5 +345,4 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
eventHub.emitEvent(EVENT_FILE_SAVED);
|
||||
return ret;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { FileEventItem } from "../../common/types";
|
||||
import type { FilePath, FilePathWithPrefix, MetaEntry, UXFileInfo, UXFileInfoStub, UXInternalFileInfoStub } from "../../lib/src/common/types";
|
||||
import type {
|
||||
FilePath,
|
||||
FilePathWithPrefix,
|
||||
MetaEntry,
|
||||
UXFileInfo,
|
||||
UXFileInfoStub,
|
||||
UXInternalFileInfoStub,
|
||||
} from "../../lib/src/common/types";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { compareFileFreshness, EVEN, getPath, getPathFromUXFileInfo, getPathWithoutPrefix, markChangesAreSame } from "../../common/utils";
|
||||
import {
|
||||
compareFileFreshness,
|
||||
EVEN,
|
||||
getPath,
|
||||
getPathWithoutPrefix,
|
||||
getStoragePathFromUXFileInfo,
|
||||
markChangesAreSame,
|
||||
} from "../../common/utils";
|
||||
import { getDocDataAsArray, isDocContentSame, readContent } from "../../lib/src/common/utils";
|
||||
import { shouldBeIgnored } from "../../lib/src/string_and_binary/path";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import { eventHub } from "../../common/events.ts";
|
||||
|
||||
export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
|
||||
get db() {
|
||||
return this.core.databaseFileAccess;
|
||||
}
|
||||
@@ -31,10 +45,14 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
if (!readFile) {
|
||||
throw new Error(`File ${file.path} is not exist on the storage`);
|
||||
}
|
||||
return readFile
|
||||
return readFile;
|
||||
}
|
||||
|
||||
async storeFileToDB(info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix, force: boolean = false, onlyChunks: boolean = false): Promise<boolean | undefined> {
|
||||
async storeFileToDB(
|
||||
info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix,
|
||||
force: boolean = false,
|
||||
onlyChunks: boolean = false
|
||||
): Promise<boolean | undefined> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
if (file == null) {
|
||||
this._log(`File ${info} is not exist on the storage`, LOG_LEVEL_VERBOSE);
|
||||
@@ -42,10 +60,13 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
// const file = item.args.file;
|
||||
if (file.isInternal) {
|
||||
this._log(`Internal file ${file.path} is not allowed to be processed on processFileEvent`, LOG_LEVEL_VERBOSE);
|
||||
this._log(
|
||||
`Internal file ${file.path} is not allowed to be processed on processFileEvent`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// First, check the file on the database
|
||||
// First, check the file on the database
|
||||
const entry = await this.db.fetchEntry(file, undefined, true, true);
|
||||
|
||||
if (!entry || entry.deleted || entry._deleted) {
|
||||
@@ -54,21 +75,9 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
if (!onlyChunks) {
|
||||
return await this.db.store(readFile);
|
||||
} else {
|
||||
return true;
|
||||
return await this.db.createChunks(readFile, false, true);
|
||||
}
|
||||
}
|
||||
// I remember that it should be processed naturally. -->
|
||||
|
||||
// // If the file is exist on the database, then it should be updated.
|
||||
// // Check the file is already conflicted or not.
|
||||
// const conflictedRevs = await this.db.getConflictedRevs(file);
|
||||
// if (conflictedRevs.length > 0) {
|
||||
// // If conflicted, then it should be stored as new conflicted file.
|
||||
// const readFile = await this.readFileFromStub(file);
|
||||
// this.db.store(readFile, true);
|
||||
// return false;
|
||||
// }
|
||||
//< --
|
||||
|
||||
// entry is exist on the database, check the difference between the file and the entry.
|
||||
|
||||
@@ -86,11 +95,9 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
if (!shouldApplied) {
|
||||
readFile = await this.readFileFromStub(file);
|
||||
if (await isDocContentSame(getDocDataAsArray(entry.data), readFile.body)) {
|
||||
if (shouldApplied) {
|
||||
// Timestamp is different but the content is same. therefore, two timestamps should be handled as same.
|
||||
// So, mark the changes are same.
|
||||
markChangesAreSame(file, file.stat.mtime, entry.mtime);
|
||||
}
|
||||
// Timestamp is different but the content is same. therefore, two timestamps should be handled as same.
|
||||
// So, mark the changes are same.
|
||||
markChangesAreSame(file, file.stat.mtime, entry.mtime);
|
||||
} else {
|
||||
shouldApplied = true;
|
||||
}
|
||||
@@ -126,10 +133,13 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
// const file = item.args.file;
|
||||
if (file.isInternal) {
|
||||
this._log(`Internal file ${file.path} is not allowed to be processed on processFileEvent`, LOG_LEVEL_VERBOSE);
|
||||
this._log(
|
||||
`Internal file ${file.path} is not allowed to be processed on processFileEvent`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// First, check the file on the database
|
||||
// First, check the file on the database
|
||||
const entry = await this.db.fetchEntry(file, undefined, true, true);
|
||||
if (!entry || entry.deleted || entry._deleted) {
|
||||
this._log(`File ${file.path} is not exist or already deleted on the database`, LOG_LEVEL_VERBOSE);
|
||||
@@ -147,24 +157,34 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
return await this.db.delete(file);
|
||||
}
|
||||
|
||||
async deleteRevisionFromDB(info: UXFileInfoStub | FilePath | FilePathWithPrefix, rev: string): Promise<boolean | undefined> {
|
||||
async deleteRevisionFromDB(
|
||||
info: UXFileInfoStub | FilePath | FilePathWithPrefix,
|
||||
rev: string
|
||||
): Promise<boolean | undefined> {
|
||||
//TODO: Possibly check the conflicting.
|
||||
return await this.db.delete(info, rev);
|
||||
}
|
||||
|
||||
|
||||
async resolveConflictedByDeletingRevision(info: UXFileInfoStub | FilePath, rev: string): Promise<boolean | undefined> {
|
||||
if (!await this.deleteRevisionFromDB(info, rev)) {
|
||||
this._log(`Failed to delete the conflicted revision ${rev} of ${getPathFromUXFileInfo(info)}`, LOG_LEVEL_VERBOSE);
|
||||
async resolveConflictedByDeletingRevision(
|
||||
info: UXFileInfoStub | FilePath,
|
||||
rev: string
|
||||
): Promise<boolean | undefined> {
|
||||
const path = getStoragePathFromUXFileInfo(info);
|
||||
if (!(await this.deleteRevisionFromDB(info, rev))) {
|
||||
this._log(`Failed to delete the conflicted revision ${rev} of ${path}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!await this.dbToStorageWithSpecificRev(info, rev, true)) {
|
||||
this._log(`Failed to apply the resolved revision ${rev} of ${getPathFromUXFileInfo(info)} to the storage`, LOG_LEVEL_VERBOSE);
|
||||
if (!(await this.dbToStorageWithSpecificRev(info, rev, true))) {
|
||||
this._log(`Failed to apply the resolved revision ${rev} of ${path} to the storage`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async dbToStorageWithSpecificRev(info: UXFileInfoStub | UXFileInfo | FilePath | null, rev: string, force?: boolean): Promise<boolean> {
|
||||
async dbToStorageWithSpecificRev(
|
||||
info: UXFileInfoStub | UXFileInfo | FilePath | null,
|
||||
rev: string,
|
||||
force?: boolean
|
||||
): Promise<boolean> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
if (file == null) {
|
||||
this._log(`File ${info} is not exist on the storage`, LOG_LEVEL_VERBOSE);
|
||||
@@ -178,14 +198,17 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
return await this.dbToStorage(docEntry, file, force);
|
||||
}
|
||||
|
||||
async dbToStorage(entryInfo: MetaEntry | FilePathWithPrefix, info: UXFileInfoStub | UXFileInfo | FilePath | null, force?: boolean): Promise<boolean> {
|
||||
async dbToStorage(
|
||||
entryInfo: MetaEntry | FilePathWithPrefix,
|
||||
info: UXFileInfoStub | UXFileInfo | FilePath | null,
|
||||
force?: boolean
|
||||
): Promise<boolean> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
const mode = file == null ? "create" : "modify";
|
||||
|
||||
const docEntry = typeof entryInfo === "string" ?
|
||||
await this.db.fetchEntryMeta(entryInfo, undefined, true) : await this.db.fetchEntryMeta(entryInfo.path, undefined, true);
|
||||
const pathFromEntryInfo = typeof entryInfo === "string" ? entryInfo : getPath(entryInfo);
|
||||
const docEntry = await this.db.fetchEntryMeta(pathFromEntryInfo, undefined, true);
|
||||
if (!docEntry) {
|
||||
this._log(`File ${entryInfo} is not exist on the database`, LOG_LEVEL_VERBOSE);
|
||||
this._log(`File ${pathFromEntryInfo} is not exist on the database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const path = getPath(docEntry);
|
||||
@@ -235,12 +258,9 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
const docData = readContent(docRead);
|
||||
|
||||
if (!existOnStorage) {
|
||||
// The file is not exist on the storage. We do not care about the differences.
|
||||
await this.storage.ensureDir(path);
|
||||
return await this.storage.writeFileAuto(path, docData, { ctime: docRead.ctime, mtime: docRead.mtime });
|
||||
}
|
||||
if (!force) {
|
||||
if (existOnStorage && !force) {
|
||||
// The file is exist on the storage. Let's check the difference between the file and the entry.
|
||||
// But, if force is true, then it should be updated.
|
||||
// Ok, we have to compare.
|
||||
let shouldApplied = false;
|
||||
// 1. if the time stamp is far different, then it should be updated.
|
||||
@@ -252,7 +272,7 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
// 2. if not, the content should be checked.
|
||||
|
||||
if (shouldApplied) {
|
||||
if (!shouldApplied) {
|
||||
const readFile = await this.readFileFromStub(existDoc);
|
||||
if (await isDocContentSame(docData, readFile.body)) {
|
||||
// The content is same. So, we do not need to update the file.
|
||||
@@ -269,10 +289,15 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
return true;
|
||||
}
|
||||
// Let's apply the changes.
|
||||
} else {
|
||||
this._log(
|
||||
`File ${docRead.path} ${existOnStorage ? "(new) " : ""} ${force ? " (forced)" : ""}`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
await this.storage.ensureDir(path);
|
||||
const ret = await this.storage.writeFileAuto(path, docData, { ctime: docRead.ctime, mtime: docRead.mtime });
|
||||
this.storage.touched(path);
|
||||
await this.storage.touched(path);
|
||||
this.storage.triggerFileEvent(mode, path);
|
||||
return ret;
|
||||
}
|
||||
@@ -281,7 +306,7 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
const eventItem = item.args;
|
||||
const type = item.type;
|
||||
const path = eventItem.file.path;
|
||||
if (!await this.core.$$isTargetFile(path)) {
|
||||
if (!(await this.core.$$isTargetFile(path))) {
|
||||
this._log(`File ${path} is not the target file`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
@@ -309,7 +334,7 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
|
||||
async $anyProcessReplicatedDoc(entry: MetaEntry): Promise<boolean | undefined> {
|
||||
return await serialized(entry.path, async () => {
|
||||
if (!await this.core.$$isTargetFile(entry.path)) {
|
||||
if (!(await this.core.$$isTargetFile(entry.path))) {
|
||||
this._log(`File ${entry.path} is not the target file`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
@@ -325,13 +350,17 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
// Nothing to do and other modules should also nothing to do.
|
||||
return true;
|
||||
} else {
|
||||
this._log(`Processing ${path} (${entry._id.substring(0, 8)}: ${entry._rev?.substring(0, 5)}) :Started...`, LOG_LEVEL_VERBOSE);
|
||||
this._log(
|
||||
`Processing ${path} (${entry._id.substring(0, 8)}: ${entry._rev?.substring(0, 5)}) :Started...`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
// Before writing (or skipped ), merging dialogue should be cancelled.
|
||||
eventHub.emitEvent("conflict-cancelled", path);
|
||||
const ret = await this.dbToStorage(entry, targetFile);
|
||||
this._log(`Processing ${path} (${entry._id.substring(0, 8)} :${entry._rev?.substring(0, 5)}) : Done`);
|
||||
return ret;
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async createAllChunks(showingNotice?: boolean): Promise<void> {
|
||||
@@ -342,11 +371,16 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
const filesStorageSrc = this.storage.getFiles();
|
||||
const incProcessed = () => {
|
||||
processed++;
|
||||
if (processed % 25 == 0) this._log(`Creating missing chunks: ${processed} of ${total} files`, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "chunkCreation");
|
||||
}
|
||||
if (processed % 25 == 0)
|
||||
this._log(
|
||||
`Creating missing chunks: ${processed} of ${total} files`,
|
||||
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
||||
"chunkCreation"
|
||||
);
|
||||
};
|
||||
const total = filesStorageSrc.length;
|
||||
const procAllChunks = filesStorageSrc.map(async (file) => {
|
||||
if (!await this.core.$$isTargetFile(file)) {
|
||||
if (!(await this.core.$$isTargetFile(file))) {
|
||||
incProcessed();
|
||||
return true;
|
||||
}
|
||||
@@ -365,6 +399,10 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
});
|
||||
await Promise.all(procAllChunks);
|
||||
this._log(`Creating chunks Done: ${processed} of ${total} files`, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "chunkCreation");
|
||||
this._log(
|
||||
`Creating chunks Done: ${processed} of ${total} files`,
|
||||
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
||||
"chunkCreation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { $f } from "../../lib/src/common/i18n";
|
||||
import { $msg } from "../../lib/src/common/i18n";
|
||||
import { LiveSyncLocalDB } from "../../lib/src/pouchdb/LiveSyncLocalDB.ts";
|
||||
import { initializeStores } from "../../common/stores.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleLocalDatabaseObsidian extends AbstractModule implements ICoreModule {
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
@@ -14,10 +13,13 @@ export class ModuleLocalDatabaseObsidian extends AbstractModule implements ICore
|
||||
await this.localDatabase.close();
|
||||
}
|
||||
const vaultName = this.core.$$getVaultName();
|
||||
this._log($f`Waiting for ready...`);
|
||||
this._log($msg("moduleLocalDatabase.logWaitingForReady"));
|
||||
this.core.localDatabase = new LiveSyncLocalDB(vaultName, this.core);
|
||||
initializeStores(vaultName);
|
||||
return await this.localDatabase.initializeDatabase();
|
||||
}
|
||||
|
||||
}
|
||||
$$isDatabaseReady(): boolean {
|
||||
return this.localDatabase != null && this.localDatabase.isReady;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AbstractModule } from "../AbstractModule";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
|
||||
export class ModulePeriodicProcess extends AbstractModule implements ICoreModule {
|
||||
|
||||
periodicSyncProcessor = new PeriodicProcessor(this.core, async () => await this.core.$$replicate());
|
||||
|
||||
_disablePeriodic() {
|
||||
@@ -11,20 +10,19 @@ export class ModulePeriodicProcess extends AbstractModule implements ICoreModule
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
_resumePeriodic() {
|
||||
this.periodicSyncProcessor.enable(this.settings.periodicReplication ? this.settings.periodicReplicationInterval * 1000 : 0);
|
||||
this.periodicSyncProcessor.enable(
|
||||
this.settings.periodicReplication ? this.settings.periodicReplicationInterval * 1000 : 0
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
$allOnUnload() {
|
||||
return this._disablePeriodic();
|
||||
|
||||
}
|
||||
$everyBeforeRealizeSetting(): Promise<boolean> {
|
||||
return this._disablePeriodic();
|
||||
|
||||
}
|
||||
$everyBeforeSuspendProcess(): Promise<boolean> {
|
||||
return this._disablePeriodic();
|
||||
|
||||
}
|
||||
$everyAfterResumeProcess(): Promise<boolean> {
|
||||
return this._resumePeriodic();
|
||||
@@ -32,4 +30,4 @@ export class ModulePeriodicProcess extends AbstractModule implements ICoreModule
|
||||
$everyAfterRealizeSetting(): Promise<boolean> {
|
||||
return this._resumePeriodic();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { ICoreModule } from "../ModuleTypes";
|
||||
import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
|
||||
|
||||
export class ModulePouchDB extends AbstractModule implements ICoreModule {
|
||||
$$createPouchDBInstance<T extends object>(name?: string, options?: PouchDB.Configuration.DatabaseConfiguration): PouchDB.Database<T> {
|
||||
$$createPouchDBInstance<T extends object>(
|
||||
name?: string,
|
||||
options?: PouchDB.Configuration.DatabaseConfiguration
|
||||
): PouchDB.Database<T> {
|
||||
const optionPass = options ?? {};
|
||||
if (this.settings.useIndexedDBAdapter) {
|
||||
optionPass.adapter = "indexeddb";
|
||||
@@ -13,4 +16,4 @@ export class ModulePouchDB extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
return new PouchDB(name, optionPass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { delay } from "octagonal-wheels/promises";
|
||||
import { FLAGMD_REDFLAG2_HR, FLAGMD_REDFLAG3_HR, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, REMOTE_COUCHDB, REMOTE_MINIO } from "../../lib/src/common/types.ts";
|
||||
import {
|
||||
FLAGMD_REDFLAG2_HR,
|
||||
FLAGMD_REDFLAG3_HR,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { Rebuilder } from "../interfaces/DatabaseRebuilder.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
import type { LiveSyncCouchDBReplicator } from "../../lib/src/replication/couchdb/LiveSyncReplicator.ts";
|
||||
import { fetchAllUsedChunks } from "../../lib/src/pouchdb/utils_couchdb.ts";
|
||||
import { EVENT_DATABASE_REBUILT, eventHub } from "src/common/events.ts";
|
||||
|
||||
export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebuilder {
|
||||
|
||||
$everyOnload(): Promise<boolean> {
|
||||
this.core.rebuilder = this;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async $performRebuildDB(method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks"): Promise<void> {
|
||||
async $performRebuildDB(
|
||||
method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks"
|
||||
): Promise<void> {
|
||||
if (method == "localOnly") {
|
||||
await this.$fetchLocal();
|
||||
}
|
||||
@@ -27,31 +36,41 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
}
|
||||
}
|
||||
|
||||
async askUsingOptionalFeature(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to enable extra features? If you are new to Self-hosted LiveSync, try the core feature first!",
|
||||
{ title: "Enable extra features", defaultOption: "No", timeout: 15 }
|
||||
)) == "yes"
|
||||
) {
|
||||
await this.core.$allAskUsingOptionalSyncFeature(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async rebuildRemote() {
|
||||
await this.core.$allSuspendExtraSync();
|
||||
this.core.settings.isConfigured = true;
|
||||
|
||||
await this.core.realizeSettingSyncMode();
|
||||
await this.core.$$realizeSettingSyncMode();
|
||||
await this.core.$$markRemoteLocked();
|
||||
await this.core.$$tryResetRemoteDatabase();
|
||||
await this.core.$$markRemoteLocked();
|
||||
await delay(500);
|
||||
await this.core.$allAskUsingOptionalSyncFeature({ enableOverwrite: true });
|
||||
await this.askUsingOptionalFeature({ enableOverwrite: true });
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllToServer(true);
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllToServer(true);
|
||||
await this.core.$$replicateAllToServer(true, true);
|
||||
}
|
||||
$rebuildRemote(): Promise<void> {
|
||||
return this.rebuildRemote();
|
||||
}
|
||||
|
||||
|
||||
async rebuildEverything() {
|
||||
await this.core.$allSuspendExtraSync();
|
||||
await this.askUseNewAdapter();
|
||||
this.core.settings.isConfigured = true;
|
||||
await this.core.realizeSettingSyncMode();
|
||||
await this.core.$$realizeSettingSyncMode();
|
||||
await this.resetLocalDatabase();
|
||||
await delay(1000);
|
||||
await this.core.$$initializeDatabase(true);
|
||||
@@ -59,12 +78,12 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
await this.core.$$tryResetRemoteDatabase();
|
||||
await this.core.$$markRemoteLocked();
|
||||
await delay(500);
|
||||
await this.core.$allAskUsingOptionalSyncFeature({ enableOverwrite: true });
|
||||
// We do not have any other devices' data, so we do not need to ask for overwriting.
|
||||
await this.askUsingOptionalFeature({ enableOverwrite: false });
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllToServer(true);
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllToServer(true);
|
||||
|
||||
await this.core.$$replicateAllToServer(true, true);
|
||||
}
|
||||
|
||||
$rebuildEverything(): Promise<void> {
|
||||
@@ -107,7 +126,6 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
await this.localDatabase.resetDatabase();
|
||||
}
|
||||
|
||||
|
||||
async suspendAllSync() {
|
||||
this.core.settings.liveSync = false;
|
||||
this.core.settings.periodicReplication = false;
|
||||
@@ -121,7 +139,10 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
async suspendReflectingDatabase() {
|
||||
if (this.core.settings.doNotSuspendOnFetching) return;
|
||||
if (this.core.settings.remoteType == REMOTE_MINIO) return;
|
||||
this._log(`Suspending reflection: Database and storage changes will not be reflected in each other until completely finished the fetching.`, LOG_LEVEL_NOTICE);
|
||||
this._log(
|
||||
`Suspending reflection: Database and storage changes will not be reflected in each other until completely finished the fetching.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.core.settings.suspendParseReplicationResult = true;
|
||||
this.core.settings.suspendFileWatching = true;
|
||||
await this.core.saveSettings();
|
||||
@@ -135,7 +156,6 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
await this.core.$$performFullScan(true);
|
||||
await this.core.$everyBeforeReplicate(false); //TODO: Check actual need of this.
|
||||
await this.core.saveSettings();
|
||||
|
||||
}
|
||||
async askUseNewAdapter() {
|
||||
if (!this.core.settings.useIndexedDBAdapter) {
|
||||
@@ -144,24 +164,35 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
const CHOICE_NO = "No, keep compatibility";
|
||||
const choices = [CHOICE_YES, CHOICE_NO];
|
||||
|
||||
const ret = await this.core.confirm.confirmWithMessage("Database adapter", message, choices, CHOICE_YES, 10);
|
||||
const ret = await this.core.confirm.confirmWithMessage(
|
||||
"Database adapter",
|
||||
message,
|
||||
choices,
|
||||
CHOICE_YES,
|
||||
10
|
||||
);
|
||||
if (ret == CHOICE_YES) {
|
||||
this.core.settings.useIndexedDBAdapter = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
async fetchLocal(makeLocalChunkBeforeSync?: boolean) {
|
||||
async fetchLocal(makeLocalChunkBeforeSync?: boolean, preventMakeLocalFilesBeforeSync?: boolean) {
|
||||
await this.core.$allSuspendExtraSync();
|
||||
await this.askUseNewAdapter();
|
||||
this.core.settings.isConfigured = true;
|
||||
await this.suspendReflectingDatabase();
|
||||
await this.core.realizeSettingSyncMode();
|
||||
await this.core.$$realizeSettingSyncMode();
|
||||
await this.resetLocalDatabase();
|
||||
await delay(1000);
|
||||
await this.core.$$openDatabase();
|
||||
this.core.isReady = true;
|
||||
// this.core.isReady = true;
|
||||
this.core.$$markIsReady();
|
||||
if (makeLocalChunkBeforeSync) {
|
||||
await this.core.fileHandler.createAllChunks(true);
|
||||
} else if (!preventMakeLocalFilesBeforeSync) {
|
||||
await this.core.$$initializeDatabase(true);
|
||||
} else {
|
||||
// Do not create local file entries before sync (Means use remote information)
|
||||
}
|
||||
await this.core.$$markRemoteResolved();
|
||||
await delay(500);
|
||||
@@ -169,7 +200,7 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
await delay(1000);
|
||||
await this.core.$$replicateAllFromServer(true);
|
||||
await this.resumeReflectingDatabase();
|
||||
await this.core.$allAskUsingOptionalSyncFeature({ enableFetch: true });
|
||||
await this.askUsingOptionalFeature({ enableFetch: true });
|
||||
}
|
||||
async fetchLocalWithRebuild() {
|
||||
return await this.fetchLocal(true);
|
||||
@@ -188,12 +219,21 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
const suffix = (await this.core.$anyGetAppId()) || "";
|
||||
this.core.settings.additionalSuffixOfDatabaseName = suffix;
|
||||
await this.core.$$resetLocalDatabase();
|
||||
eventHub.emitEvent(EVENT_DATABASE_REBUILT);
|
||||
}
|
||||
async fetchRemoteChunks() {
|
||||
if (!this.core.settings.doNotSuspendOnFetching && this.core.settings.readChunksOnline && this.core.settings.remoteType == REMOTE_COUCHDB) {
|
||||
if (
|
||||
!this.core.settings.doNotSuspendOnFetching &&
|
||||
this.core.settings.readChunksOnline &&
|
||||
this.core.settings.remoteType == REMOTE_COUCHDB
|
||||
) {
|
||||
this._log(`Fetching chunks`, LOG_LEVEL_NOTICE);
|
||||
const replicator = this.core.getReplicator() as LiveSyncCouchDBReplicator;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(this.settings, this.core.getIsMobile(), true);
|
||||
const replicator = this.core.$$getReplicator() as LiveSyncCouchDBReplicator;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
this.settings,
|
||||
this.core.$$isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDB == "string") {
|
||||
this._log(remoteDB, LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
@@ -208,9 +248,14 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
if (i++ % 10) this._log(`Check and Processing ${i} / ${files.length}`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
if (i++ % 10)
|
||||
this._log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await this.core.$anyResolveConflictByNewest(file);
|
||||
}
|
||||
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,167 +8,220 @@ import { purgeUnreferencedChunks, balanceChunkPurgedDBs } from "../../lib/src/po
|
||||
import { LiveSyncCouchDBReplicator } from "../../lib/src/replication/couchdb/LiveSyncReplicator";
|
||||
import { throttle } from "octagonal-wheels/function";
|
||||
import { arrayToChunkedArray } from "octagonal-wheels/collection";
|
||||
import { SYNCINFO_ID, VER, type EntryBody, type EntryDoc, type LoadedEntry, type MetaEntry } from "../../lib/src/common/types";
|
||||
import {
|
||||
SYNCINFO_ID,
|
||||
VER,
|
||||
type EntryBody,
|
||||
type EntryDoc,
|
||||
type EntryLeaf,
|
||||
type LoadedEntry,
|
||||
type MetaEntry,
|
||||
} from "../../lib/src/common/types";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { getPath, isChunk, isValidPath, scheduleTask } from "../../common/utils";
|
||||
import { sendValue } from "octagonal-wheels/messagepassing/signal";
|
||||
import {
|
||||
getPath,
|
||||
isChunk,
|
||||
isValidPath,
|
||||
rateLimitedSharedExecution,
|
||||
scheduleTask,
|
||||
updatePreviousExecutionTime,
|
||||
} from "../../common/utils";
|
||||
import { isAnyNote } from "../../lib/src/common/utils";
|
||||
import { EVENT_FILE_SAVED, eventHub } from "../../common/events";
|
||||
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
|
||||
import { globalSlipBoard } from "../../lib/src/bureau/bureau";
|
||||
import { $msg } from "../../lib/src/common/i18n";
|
||||
|
||||
const KEY_REPLICATION_ON_EVENT = "replicationOnEvent";
|
||||
const REPLICATION_ON_EVENT_FORECASTED_TIME = 5000;
|
||||
export class ModuleReplicator extends AbstractModule implements ICoreModule {
|
||||
|
||||
$everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
eventHub.onEvent(EVENT_FILE_SAVED, () => {
|
||||
if (this.settings.syncOnSave && !this.core.suspended) {
|
||||
scheduleTask("perform-replicate-after-save", 250, () => this.core.$$waitForReplicationOnce());
|
||||
if (this.settings.syncOnSave && !this.core.$$isSuspended()) {
|
||||
scheduleTask("perform-replicate-after-save", 250, () => this.core.$$replicateByEvent());
|
||||
}
|
||||
})
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async setReplicator() {
|
||||
const replicator = await this.core.$anyNewReplicator();
|
||||
if (!replicator) {
|
||||
this._log("No replicator is available, this is the fatal error.", LOG_LEVEL_NOTICE);
|
||||
return false
|
||||
this._log($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
this.core.replicator = replicator;
|
||||
await yieldMicrotask();
|
||||
return true;
|
||||
}
|
||||
|
||||
$$getReplicator(): LiveSyncAbstractReplicator {
|
||||
return this.core.replicator;
|
||||
}
|
||||
|
||||
$everyOnInitializeDatabase(db: LiveSyncLocalDB): Promise<boolean> {
|
||||
return this.setReplicator();
|
||||
}
|
||||
|
||||
$everyOnResetDatabase(db: LiveSyncLocalDB): Promise<boolean> {
|
||||
return this.setReplicator();
|
||||
|
||||
}
|
||||
|
||||
async $everyBeforeReplicate(showMessage: boolean): Promise<boolean> {
|
||||
await this.loadQueuedFiles();
|
||||
return true;
|
||||
}
|
||||
|
||||
async $$replicate(showMessage: boolean = false): Promise<boolean | void> {
|
||||
//--?
|
||||
if (!this.core.isReady) return;
|
||||
try {
|
||||
updatePreviousExecutionTime(KEY_REPLICATION_ON_EVENT, REPLICATION_ON_EVENT_FORECASTED_TIME);
|
||||
return await this.$$_replicate(showMessage);
|
||||
} finally {
|
||||
updatePreviousExecutionTime(KEY_REPLICATION_ON_EVENT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* obsolete method. No longer maintained and will be removed in the future.
|
||||
* @deprecated v0.24.17
|
||||
* @param showMessage If true, show message to the user.
|
||||
*/
|
||||
async cleaned(showMessage: boolean) {
|
||||
Logger(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
await skipIfDuplicated("cleanup", async () => {
|
||||
const count = await purgeUnreferencedChunks(this.localDatabase.localDatabase, true);
|
||||
const message = `The remote database has been cleaned up.
|
||||
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
|
||||
However, If there are many chunks to be deleted, maybe fetching again is faster.
|
||||
We will lose the history of this device if we fetch the remote database again.
|
||||
Even if you choose to clean up, you will see this option again if you exit Obsidian and then synchronise again.`;
|
||||
const CHOICE_FETCH = "Fetch again";
|
||||
const CHOICE_CLEAN = "Cleanup";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
const ret = await this.core.confirm.confirmWithMessage(
|
||||
"Cleaned",
|
||||
message,
|
||||
[CHOICE_FETCH, CHOICE_CLEAN, CHOICE_DISMISS],
|
||||
CHOICE_DISMISS,
|
||||
30
|
||||
);
|
||||
if (ret == CHOICE_FETCH) {
|
||||
await this.core.rebuilder.$performRebuildDB("localOnly");
|
||||
}
|
||||
if (ret == CHOICE_CLEAN) {
|
||||
const replicator = this.core.$$getReplicator();
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
this.settings,
|
||||
this.core.$$isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDB == "string") {
|
||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.hashCaches.clear();
|
||||
// Perform the synchronisation once.
|
||||
if (await this.core.replicator.openReplication(this.settings, false, showMessage, true)) {
|
||||
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.hashCaches.clear();
|
||||
await this.core.$$getReplicator().markRemoteResolved(this.settings);
|
||||
Logger("The local database has been cleaned up.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
} else {
|
||||
Logger(
|
||||
"Replication has been cancelled. Please try it again.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async $$_replicate(showMessage: boolean = false): Promise<boolean | void> {
|
||||
//--?
|
||||
if (!this.core.$$isReady()) return;
|
||||
if (isLockAcquired("cleanup")) {
|
||||
Logger("Database cleaning up is in process. replication has been cancelled", LOG_LEVEL_NOTICE);
|
||||
Logger($msg("Replicator.Message.Cleaned"), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
if (this.settings.versionUpFlash != "") {
|
||||
Logger("Open settings and check message, please. replication has been cancelled.", LOG_LEVEL_NOTICE);
|
||||
Logger($msg("Replicator.Message.VersionUpFlash"), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
if (!await this.core.$everyCommitPendingFileEvent()) {
|
||||
Logger("Some file events are pending. Replication has been cancelled.", LOG_LEVEL_NOTICE);
|
||||
if (!(await this.core.$everyCommitPendingFileEvent())) {
|
||||
Logger($msg("Replicator.Message.Pending"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!await this.core.$everyBeforeReplicate(showMessage)) {
|
||||
Logger(`Replication has been cancelled by some module failure`, LOG_LEVEL_NOTICE);
|
||||
if (!(await this.core.$everyBeforeReplicate(showMessage))) {
|
||||
Logger($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
//<-- Here could be an module.
|
||||
const ret = await this.core.replicator.openReplication(this.settings, false, showMessage, false);
|
||||
if (!ret) {
|
||||
if (this.core.replicator.tweakSettingsMismatched) {
|
||||
await this.core.$$askResolvingMismatchedTweaks();
|
||||
|
||||
if (this.core.replicator.tweakSettingsMismatched && this.core.replicator.preferredTweakValue) {
|
||||
await this.core.$$askResolvingMismatchedTweaks(this.core.replicator.preferredTweakValue);
|
||||
} else {
|
||||
if (this.core.replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||
if (this.core.replicator.remoteCleaned && this.settings.useIndexedDBAdapter) {
|
||||
Logger(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
await skipIfDuplicated("cleanup", async () => {
|
||||
const count = await purgeUnreferencedChunks(this.localDatabase.localDatabase, true);
|
||||
const message = `The remote database has been cleaned up.
|
||||
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
|
||||
However, If there are many chunks to be deleted, maybe fetching again is faster.
|
||||
We will lose the history of this device if we fetch the remote database again.
|
||||
Even if you choose to clean up, you will see this option again if you exit Obsidian and then synchronise again.`
|
||||
const CHOICE_FETCH = "Fetch again";
|
||||
const CHOICE_CLEAN = "Cleanup";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
const ret = await this.core.confirm.confirmWithMessage("Cleaned", message, [
|
||||
CHOICE_FETCH,
|
||||
CHOICE_CLEAN,
|
||||
CHOICE_DISMISS], CHOICE_DISMISS, 30);
|
||||
if (ret == CHOICE_FETCH) {
|
||||
await this.core.rebuilder.$performRebuildDB("localOnly");
|
||||
}
|
||||
if (ret == CHOICE_CLEAN) {
|
||||
const replicator = this.core.getReplicator();
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(this.settings, this.core.getIsMobile(), true);
|
||||
if (typeof remoteDB == "string") {
|
||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.hashCaches.clear();
|
||||
// Perform the synchronisation once.
|
||||
if (await this.core.replicator.openReplication(this.settings, false, showMessage, true)) {
|
||||
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.hashCaches.clear();
|
||||
await this.core.getReplicator().markRemoteResolved(this.settings);
|
||||
Logger("The local database has been cleaned up.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO)
|
||||
} else {
|
||||
Logger("Replication has been cancelled. Please try it again.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO)
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
await this.cleaned(showMessage);
|
||||
} else {
|
||||
const message = `
|
||||
The remote database has been rebuilt.
|
||||
To synchronize, this device must fetch everything again once.
|
||||
Or if you are sure know what had been happened, we can unlock the database from the setting dialog.
|
||||
`
|
||||
const CHOICE_FETCH = "Fetch again";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
const ret = await this.core.confirm.confirmWithMessage("Locked", message, [
|
||||
CHOICE_FETCH,
|
||||
CHOICE_DISMISS], CHOICE_DISMISS, 10);
|
||||
const message = $msg("Replicator.Dialogue.Locked.Message");
|
||||
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
|
||||
const CHOICE_DISMISS = $msg("Replicator.Dialogue.Locked.Action.Dismiss");
|
||||
const CHOICE_UNLOCK = $msg("Replicator.Dialogue.Locked.Action.Unlock");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[CHOICE_FETCH, CHOICE_UNLOCK, CHOICE_DISMISS],
|
||||
{
|
||||
title: $msg("Replicator.Dialogue.Locked.Title"),
|
||||
defaultAction: CHOICE_DISMISS,
|
||||
timeout: 60,
|
||||
}
|
||||
);
|
||||
if (ret == CHOICE_FETCH) {
|
||||
const CHOICE_RESTART = "Restart";
|
||||
const CHOICE_WITHOUT_RESTART = "Without restart";
|
||||
if (await this.core.confirm.askSelectStringDialogue(
|
||||
"Self-hosted LiveSync restarts Obsidian to fetch everything safely. However, you can do it without restarting. Please choose one.",
|
||||
[CHOICE_RESTART, CHOICE_WITHOUT_RESTART], {
|
||||
title: "Fetch again",
|
||||
defaultAction: CHOICE_RESTART,
|
||||
timeout: 30,
|
||||
}
|
||||
) == CHOICE_RESTART) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
// await this.core.$$scheduleAppReload();
|
||||
return;
|
||||
} else {
|
||||
await this.core.rebuilder.$performRebuildDB("localOnly");
|
||||
}
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE);
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.core.$$scheduleAppReload();
|
||||
return;
|
||||
} else if (ret == CHOICE_UNLOCK) {
|
||||
await this.core.replicator.markRemoteResolved(this.settings);
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
async $$replicateByEvent(): Promise<boolean | void> {
|
||||
const least = this.settings.syncMinimumInterval;
|
||||
if (least > 0) {
|
||||
return rateLimitedSharedExecution(KEY_REPLICATION_ON_EVENT, least, async () => {
|
||||
return await this.$$replicate();
|
||||
});
|
||||
}
|
||||
return await shareRunningResult(`replication`, () => this.core.$$replicate());
|
||||
}
|
||||
$$parseReplicationResult(docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>): void {
|
||||
if (this.settings.suspendParseReplicationResult && !this.replicationResultProcessor.isSuspended) {
|
||||
this.replicationResultProcessor.suspend()
|
||||
this.replicationResultProcessor.suspend();
|
||||
}
|
||||
this.replicationResultProcessor.enqueueAll(docs);
|
||||
if (!this.settings.suspendParseReplicationResult && this.replicationResultProcessor.isSuspended) {
|
||||
this.replicationResultProcessor.resume()
|
||||
this.replicationResultProcessor.resume();
|
||||
}
|
||||
}
|
||||
_saveQueuedFiles = throttle(() => {
|
||||
const saveData = this.replicationResultProcessor._queue.filter(e => e !== undefined && e !== null).map((e) => e?._id ?? "" as string) as string[];
|
||||
const kvDBKey = "queued-files"
|
||||
const saveData = this.replicationResultProcessor._queue
|
||||
.filter((e) => e !== undefined && e !== null)
|
||||
.map((e) => e?._id ?? ("" as string)) as string[];
|
||||
const kvDBKey = "queued-files";
|
||||
// localStorage.setItem(lsKey, saveData);
|
||||
fireAndForget(() => this.core.kvDB.set(kvDBKey, saveData));
|
||||
}, 100);
|
||||
@@ -178,145 +231,191 @@ Or if you are sure know what had been happened, we can unlock the database from
|
||||
async loadQueuedFiles() {
|
||||
if (this.settings.suspendParseReplicationResult) return;
|
||||
if (!this.settings.isConfigured) return;
|
||||
const kvDBKey = "queued-files"
|
||||
const kvDBKey = "queued-files";
|
||||
// const ids = [...new Set(JSON.parse(localStorage.getItem(lsKey) || "[]"))] as string[];
|
||||
const ids = [...new Set(await this.core.kvDB.get<string[]>(kvDBKey) ?? [])];
|
||||
const ids = [...new Set((await this.core.kvDB.get<string[]>(kvDBKey)) ?? [])];
|
||||
const batchSize = 100;
|
||||
const chunkedIds = arrayToChunkedArray(ids, batchSize);
|
||||
|
||||
// suspendParseReplicationResult is true, so we have to resume it if it is suspended.
|
||||
if (this.replicationResultProcessor.isSuspended) {
|
||||
this.replicationResultProcessor.resume();
|
||||
}
|
||||
for await (const idsBatch of chunkedIds) {
|
||||
const ret = await this.localDatabase.allDocsRaw<EntryDoc>({
|
||||
keys: idsBatch,
|
||||
include_docs: true,
|
||||
limit: 100
|
||||
limit: 100,
|
||||
});
|
||||
const docs = ret.rows.filter(e => e.doc).map(e => e.doc) as PouchDB.Core.ExistingDocument<EntryDoc>[];
|
||||
const errors = ret.rows.filter(e => !e.doc && !e.value.deleted);
|
||||
const docs = ret.rows.filter((e) => e.doc).map((e) => e.doc) as PouchDB.Core.ExistingDocument<EntryDoc>[];
|
||||
const errors = ret.rows.filter((e) => !e.doc && !e.value.deleted);
|
||||
if (errors.length > 0) {
|
||||
Logger("Some queued processes were not resurrected");
|
||||
Logger(JSON.stringify(errors), LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.replicationResultProcessor.enqueueAll(docs);
|
||||
await this.replicationResultProcessor.waitForAllProcessed();
|
||||
}
|
||||
|
||||
if (this.replicationResultProcessor.isSuspended) {
|
||||
this.replicationResultProcessor.resume();
|
||||
}
|
||||
await this.replicationResultProcessor.waitForAllProcessed();
|
||||
}
|
||||
|
||||
replicationResultProcessor = new QueueProcessor(async (docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => {
|
||||
if (this.settings.suspendParseReplicationResult) return;
|
||||
const change = docs[0];
|
||||
if (!change) return;
|
||||
if (isChunk(change._id)) {
|
||||
// SendSignal?
|
||||
// this.parseIncomingChunk(change);
|
||||
sendValue(`leaf-${change._id}`, change);
|
||||
return;
|
||||
}
|
||||
if (await this.core.$anyModuleParsedReplicationResultItem(change)) return;
|
||||
// any addon needs this item?
|
||||
// for (const proc of this.core.addOns) {
|
||||
// if (await proc.parseReplicationResultItem(change)) {
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
if (change.type == "versioninfo") {
|
||||
if (change.version > VER) {
|
||||
this.core.replicator.closeReplication();
|
||||
Logger(`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (change._id == SYNCINFO_ID || // Synchronisation information data
|
||||
change._id.startsWith("_design") //design document
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isAnyNote(change)) {
|
||||
if (this.databaseQueuedProcessor._isSuspended) {
|
||||
Logger(`Processing scheduled: ${change.path}`, LOG_LEVEL_INFO);
|
||||
}
|
||||
const size = change.size;
|
||||
if (this.core.$$isFileSizeExceeded(size)) {
|
||||
Logger(`Processing ${change.path} has been skipped due to file size exceeding the limit`, LOG_LEVEL_NOTICE);
|
||||
replicationResultProcessor = new QueueProcessor(
|
||||
async (docs: PouchDB.Core.ExistingDocument<EntryDoc>[]) => {
|
||||
if (this.settings.suspendParseReplicationResult) return;
|
||||
const change = docs[0];
|
||||
if (!change) return;
|
||||
if (isChunk(change._id)) {
|
||||
globalSlipBoard.submit("read-chunk", change._id, change as EntryLeaf);
|
||||
return;
|
||||
}
|
||||
this.databaseQueuedProcessor.enqueue(change);
|
||||
}
|
||||
return;
|
||||
}, {
|
||||
batchSize: 1,
|
||||
suspended: true,
|
||||
concurrentLimit: 100,
|
||||
delay: 0,
|
||||
totalRemainingReactiveSource: this.core.replicationResultCount
|
||||
}).replaceEnqueueProcessor((queue, newItem) => {
|
||||
const q = queue.filter(e => e._id != newItem._id);
|
||||
return [...q, newItem];
|
||||
}).startPipeline().onUpdateProgress(() => {
|
||||
this.saveQueuedFiles();
|
||||
});
|
||||
|
||||
databaseQueuedProcessor = new QueueProcessor(async (docs: EntryBody[]) => {
|
||||
const dbDoc = docs[0] as LoadedEntry; // It has no `data`
|
||||
const path = getPath(dbDoc);
|
||||
// If `Read chunks online` is disabled, chunks should be transferred before here.
|
||||
// However, in some cases, chunks are after that. So, if missing chunks exist, we have to wait for them.
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, {}, false, true, true);
|
||||
if (!doc) {
|
||||
Logger(`Something went wrong while gathering content of ${path} (${dbDoc._id.substring(0, 8)}, ${dbDoc._rev?.substring(0, 10)}) `, LOG_LEVEL_NOTICE)
|
||||
if (await this.core.$anyModuleParsedReplicationResultItem(change)) return;
|
||||
// any addon needs this item?
|
||||
// for (const proc of this.core.addOns) {
|
||||
// if (await proc.parseReplicationResultItem(change)) {
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
if (change.type == "versioninfo") {
|
||||
if (change.version > VER) {
|
||||
this.core.replicator.closeReplication();
|
||||
Logger(
|
||||
`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
change._id == SYNCINFO_ID || // Synchronisation information data
|
||||
change._id.startsWith("_design") //design document
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isAnyNote(change)) {
|
||||
const docPath = getPath(change);
|
||||
if (!(await this.core.$$isTargetFile(docPath))) {
|
||||
Logger(`Skipped: ${docPath}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (this.databaseQueuedProcessor._isSuspended) {
|
||||
Logger(`Processing scheduled: ${docPath}`, LOG_LEVEL_INFO);
|
||||
}
|
||||
const size = change.size;
|
||||
if (this.core.$$isFileSizeExceeded(size)) {
|
||||
Logger(
|
||||
`Processing ${docPath} has been skipped due to file size exceeding the limit`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.databaseQueuedProcessor.enqueue(change);
|
||||
}
|
||||
return;
|
||||
},
|
||||
{
|
||||
batchSize: 1,
|
||||
suspended: true,
|
||||
concurrentLimit: 100,
|
||||
delay: 0,
|
||||
totalRemainingReactiveSource: this.core.replicationResultCount,
|
||||
}
|
||||
)
|
||||
.replaceEnqueueProcessor((queue, newItem) => {
|
||||
const q = queue.filter((e) => e._id != newItem._id);
|
||||
return [...q, newItem];
|
||||
})
|
||||
.startPipeline()
|
||||
.onUpdateProgress(() => {
|
||||
this.saveQueuedFiles();
|
||||
});
|
||||
|
||||
if (await this.core.$anyProcessOptionalSyncFiles(dbDoc)) {
|
||||
// Already processed
|
||||
} else if (isValidPath(getPath(doc))) {
|
||||
this.storageApplyingProcessor.enqueue(doc as MetaEntry);
|
||||
} else {
|
||||
Logger(`Skipped: ${doc._id.substring(0, 8)}`, LOG_LEVEL_VERBOSE);
|
||||
databaseQueuedProcessor = new QueueProcessor(
|
||||
async (docs: EntryBody[]) => {
|
||||
const dbDoc = docs[0] as LoadedEntry; // It has no `data`
|
||||
const path = getPath(dbDoc);
|
||||
|
||||
// If `Read chunks online` is disabled, chunks should be transferred before here.
|
||||
// However, in some cases, chunks are after that. So, if missing chunks exist, we have to wait for them.
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true);
|
||||
if (!doc) {
|
||||
Logger(
|
||||
`Something went wrong while gathering content of ${path} (${dbDoc._id.substring(0, 8)}, ${dbDoc._rev?.substring(0, 10)}) `,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.core.$anyProcessOptionalSyncFiles(dbDoc)) {
|
||||
// Already processed
|
||||
} else if (isValidPath(getPath(doc))) {
|
||||
this.storageApplyingProcessor.enqueue(doc as MetaEntry);
|
||||
} else {
|
||||
Logger(`Skipped: ${path} (${doc._id.substring(0, 8)})`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return;
|
||||
},
|
||||
{
|
||||
suspended: true,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
yieldThreshold: 1,
|
||||
delay: 0,
|
||||
totalRemainingReactiveSource: this.core.databaseQueueCount,
|
||||
}
|
||||
return;
|
||||
}, {
|
||||
suspended: true,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
yieldThreshold: 1,
|
||||
delay: 0,
|
||||
totalRemainingReactiveSource: this.core.databaseQueueCount
|
||||
}).replaceEnqueueProcessor((queue, newItem) => {
|
||||
const q = queue.filter(e => e._id != newItem._id);
|
||||
return [...q, newItem];
|
||||
}).startPipeline();
|
||||
)
|
||||
.replaceEnqueueProcessor((queue, newItem) => {
|
||||
const q = queue.filter((e) => e._id != newItem._id);
|
||||
return [...q, newItem];
|
||||
})
|
||||
.startPipeline();
|
||||
|
||||
|
||||
storageApplyingProcessor = new QueueProcessor(async (docs: MetaEntry[]) => {
|
||||
const entry = docs[0];
|
||||
await this.core.$anyProcessReplicatedDoc(entry);
|
||||
return;
|
||||
}, {
|
||||
suspended: true,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 6,
|
||||
yieldThreshold: 1,
|
||||
delay: 0,
|
||||
totalRemainingReactiveSource: this.core.storageApplyingCount
|
||||
}).replaceEnqueueProcessor((queue, newItem) => {
|
||||
const q = queue.filter(e => e._id != newItem._id);
|
||||
return [...q, newItem];
|
||||
}).startPipeline()
|
||||
storageApplyingProcessor = new QueueProcessor(
|
||||
async (docs: MetaEntry[]) => {
|
||||
const entry = docs[0];
|
||||
await this.core.$anyProcessReplicatedDoc(entry);
|
||||
return;
|
||||
},
|
||||
{
|
||||
suspended: true,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 6,
|
||||
yieldThreshold: 1,
|
||||
delay: 0,
|
||||
totalRemainingReactiveSource: this.core.storageApplyingCount,
|
||||
}
|
||||
)
|
||||
.replaceEnqueueProcessor((queue, newItem) => {
|
||||
const q = queue.filter((e) => e._id != newItem._id);
|
||||
return [...q, newItem];
|
||||
})
|
||||
.startPipeline();
|
||||
|
||||
$everyBeforeSuspendProcess(): Promise<boolean> {
|
||||
this.core.replicator.closeReplication();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async $$replicateAllToServer(showingNotice: boolean = false): Promise<boolean> {
|
||||
if (!this.core.isReady) return false;
|
||||
if (!await this.core.$everyBeforeReplicate(showingNotice)) {
|
||||
Logger(`Replication has been cancelled by some module failure`, LOG_LEVEL_NOTICE);
|
||||
async $$replicateAllToServer(
|
||||
showingNotice: boolean = false,
|
||||
sendChunksInBulkDisabled: boolean = false
|
||||
): Promise<boolean> {
|
||||
if (!this.core.$$isReady()) return false;
|
||||
if (!(await this.core.$everyBeforeReplicate(showingNotice))) {
|
||||
Logger($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (this.core.replicator instanceof LiveSyncCouchDBReplicator) {
|
||||
if (await this.core.confirm.askYesNoDialog("Do you want to send all chunks before replication?", { defaultOption: "No", timeout: 20 }) == "yes") {
|
||||
await this.core.replicator.sendChunks(this.core.settings, undefined, true, 0);
|
||||
if (!sendChunksInBulkDisabled) {
|
||||
if (this.core.replicator instanceof LiveSyncCouchDBReplicator) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Do you want to send all chunks before replication?", {
|
||||
defaultOption: "No",
|
||||
timeout: 20,
|
||||
})) == "yes"
|
||||
) {
|
||||
await this.core.replicator.sendChunks(this.core.settings, undefined, true, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
const ret = await this.core.replicator.replicateAllToServer(this.settings, showingNotice);
|
||||
@@ -326,18 +425,11 @@ Or if you are sure know what had been happened, we can unlock the database from
|
||||
return !checkResult;
|
||||
}
|
||||
async $$replicateAllFromServer(showingNotice: boolean = false): Promise<boolean> {
|
||||
if (!this.core.isReady) return false;
|
||||
if (!this.core.$$isReady()) return false;
|
||||
const ret = await this.core.replicator.replicateAllFromServer(this.settings, showingNotice);
|
||||
if (ret) return true;
|
||||
const checkResult = await this.core.$anyAfterConnectCheckFailed();
|
||||
if (checkResult == "CHECKAGAIN") return await this.core.$$replicateAllFromServer(showingNotice);
|
||||
return !checkResult;
|
||||
}
|
||||
|
||||
async $$waitForReplicationOnce(): Promise<boolean | void> {
|
||||
return await shareRunningResult(`replication`, () => this.core.$$replicate());
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { REMOTE_MINIO, type RemoteDBSettings } from "../../lib/src/common/types";
|
||||
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "../../lib/src/common/types";
|
||||
import { LiveSyncCouchDBReplicator } from "../../lib/src/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
@@ -9,14 +9,13 @@ export class ModuleReplicatorCouchDB extends AbstractModule implements ICoreModu
|
||||
$anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
|
||||
if (settings.remoteType == REMOTE_MINIO) {
|
||||
return undefined!
|
||||
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
|
||||
return undefined!;
|
||||
}
|
||||
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
|
||||
|
||||
}
|
||||
$everyAfterResumeProcess(): Promise<boolean> {
|
||||
if (this.settings.remoteType != REMOTE_MINIO) {
|
||||
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
|
||||
// If LiveSync enabled, open replication
|
||||
if (this.settings.liveSync) {
|
||||
fireAndForget(() => this.core.replicator.openReplication(this.settings, true, false, false));
|
||||
@@ -30,4 +29,4 @@ export class ModuleReplicatorCouchDB extends AbstractModule implements ICoreModu
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,4 @@ export class ModuleReplicatorMinIO extends AbstractModule implements ICoreModule
|
||||
}
|
||||
return undefined!;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
30
src/modules/core/ModuleReplicatorP2P.ts
Normal file
30
src/modules/core/ModuleReplicatorP2P.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { REMOTE_P2P, type RemoteDBSettings } from "../../lib/src/common/types";
|
||||
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
import { LiveSyncTrysteroReplicator } from "../../lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
|
||||
export class ModuleReplicatorP2P extends AbstractModule implements ICoreModule {
|
||||
$anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_P2P) {
|
||||
return Promise.resolve(new LiveSyncTrysteroReplicator(this.core));
|
||||
}
|
||||
return undefined!;
|
||||
}
|
||||
$everyAfterResumeProcess(): Promise<boolean> {
|
||||
if (this.settings.remoteType == REMOTE_P2P) {
|
||||
// // If LiveSync enabled, open replication
|
||||
// if (this.settings.liveSync) {
|
||||
// fireAndForget(() => this.core.replicator.openReplication(this.settings, true, false, false));
|
||||
// }
|
||||
// // If sync on start enabled, open replication
|
||||
// if (!this.settings.liveSync && this.settings.syncOnStart) {
|
||||
// // Possibly ok as if only share the result
|
||||
// fireAndForget(() => this.core.replicator.openReplication(this.settings, false, false, false));
|
||||
// }
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,35 @@
|
||||
import { LRUCache } from "octagonal-wheels/memory/LRUCache";
|
||||
import { getPathFromUXFileInfo, id2path, isInternalMetadata, path2id, stripInternalMetadataPrefix, useMemo } from "../../common/utils";
|
||||
import { LOG_LEVEL_VERBOSE, type DocumentID, type EntryHasPath, type FilePath, type FilePathWithPrefix, type ObsidianLiveSyncSettings, type UXFileInfoStub } from "../../lib/src/common/types";
|
||||
import { addPrefix, isAcceptedAll, stripAllPrefixes } from "../../lib/src/string_and_binary/path";
|
||||
import {
|
||||
getStoragePathFromUXFileInfo,
|
||||
id2path,
|
||||
isInternalMetadata,
|
||||
path2id,
|
||||
stripInternalMetadataPrefix,
|
||||
useMemo,
|
||||
} from "../../common/utils";
|
||||
import {
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type DocumentID,
|
||||
type EntryHasPath,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type UXFileInfoStub,
|
||||
} from "../../lib/src/common/types";
|
||||
import { addPrefix, isAcceptedAll } from "../../lib/src/string_and_binary/path";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import type { ICoreModule } from "../ModuleTypes";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED, eventHub } from "../../common/events";
|
||||
import { isDirty } from "../../lib/src/common/utils";
|
||||
export class ModuleTargetFilter extends AbstractModule implements ICoreModule {
|
||||
|
||||
reloadIgnoreFiles() {
|
||||
this.ignoreFiles = this.settings.ignoreFiles.split(",").map(e => e.trim());
|
||||
|
||||
this.ignoreFiles = this.settings.ignoreFiles.split(",").map((e) => e.trim());
|
||||
}
|
||||
$everyOnload(): Promise<boolean> {
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (evt: CustomEvent<ObsidianLiveSyncSettings>) => {
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (evt: ObsidianLiveSyncSettings) => {
|
||||
this.reloadIgnoreFiles();
|
||||
});
|
||||
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, (evt: CustomEvent<ObsidianLiveSyncSettings>) => {
|
||||
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
|
||||
this.reloadIgnoreFiles();
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
@@ -32,10 +45,13 @@ export class ModuleTargetFilter extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
async $$path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise<DocumentID> {
|
||||
const destPath = addPrefix(filename, prefix ?? "");
|
||||
return await path2id(destPath, this.settings.usePathObfuscation ? this.settings.passphrase : "", !this.settings.handleFilenameCaseSensitive);
|
||||
return await path2id(
|
||||
destPath,
|
||||
this.settings.usePathObfuscation ? this.settings.passphrase : "",
|
||||
!this.settings.handleFilenameCaseSensitive
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
$$isFileSizeExceeded(size: number) {
|
||||
if (this.settings.syncMaxSizeInMB > 0 && size > 0) {
|
||||
if (this.settings.syncMaxSizeInMB * 1024 * 1024 < size) {
|
||||
@@ -45,57 +61,62 @@ export class ModuleTargetFilter extends AbstractModule implements ICoreModule {
|
||||
return false;
|
||||
}
|
||||
|
||||
$$markFileListPossiblyChanged(): void {
|
||||
this.totalFileEventCount++;
|
||||
}
|
||||
totalFileEventCount = 0;
|
||||
get fileListPossiblyChanged() {
|
||||
if (isDirty("totalFileEventCount", this.core.totalFileEventCount)) {
|
||||
if (isDirty("totalFileEventCount", this.totalFileEventCount)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async $$isTargetFile(file: string | UXFileInfoStub, keepFileCheckList = false) {
|
||||
|
||||
const fileCount = useMemo<Record<string, number>>({
|
||||
key: "fileCount", // forceUpdate: !keepFileCheckList,
|
||||
}, (ctx, prev) => {
|
||||
if (keepFileCheckList && prev) return prev;
|
||||
if (!keepFileCheckList && prev && !this.fileListPossiblyChanged) {
|
||||
return prev;
|
||||
}
|
||||
const fileList = (ctx.get("fileList") ?? []) as FilePathWithPrefix[];
|
||||
// const fileNameList = (ctx.get("fileNameList") ?? []) as FilePath[];
|
||||
// const fileNames =
|
||||
const vaultFiles = this.core.storageAccess.getFileNames().sort();
|
||||
if (prev && vaultFiles.length == fileList.length) {
|
||||
const fl3 = new Set([...fileList, ...vaultFiles]);
|
||||
if (fileList.length == fl3.size && vaultFiles.length == fl3.size) {
|
||||
const fileCount = useMemo<Record<string, number>>(
|
||||
{
|
||||
key: "fileCount", // forceUpdate: !keepFileCheckList,
|
||||
},
|
||||
(ctx, prev) => {
|
||||
if (keepFileCheckList && prev) return prev;
|
||||
if (!keepFileCheckList && prev && !this.fileListPossiblyChanged) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
ctx.set("fileList", vaultFiles);
|
||||
|
||||
|
||||
const fileCount: Record<string, number> = {};
|
||||
for (const file of vaultFiles) {
|
||||
const lc = file.toLowerCase();
|
||||
if (!fileCount[lc]) {
|
||||
fileCount[lc] = 1;
|
||||
} else {
|
||||
fileCount[lc]++;
|
||||
const fileList = (ctx.get("fileList") ?? []) as FilePathWithPrefix[];
|
||||
// const fileNameList = (ctx.get("fileNameList") ?? []) as FilePath[];
|
||||
// const fileNames =
|
||||
const vaultFiles = this.core.storageAccess.getFileNames().sort();
|
||||
if (prev && vaultFiles.length == fileList.length) {
|
||||
const fl3 = new Set([...fileList, ...vaultFiles]);
|
||||
if (fileList.length == fl3.size && vaultFiles.length == fl3.size) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fileCount;
|
||||
})
|
||||
ctx.set("fileList", vaultFiles);
|
||||
|
||||
const filepath = getPathFromUXFileInfo(file);
|
||||
const fileCount: Record<string, number> = {};
|
||||
for (const file of vaultFiles) {
|
||||
const lc = file.toLowerCase();
|
||||
if (!fileCount[lc]) {
|
||||
fileCount[lc] = 1;
|
||||
} else {
|
||||
fileCount[lc]++;
|
||||
}
|
||||
}
|
||||
return fileCount;
|
||||
}
|
||||
);
|
||||
|
||||
const filepath = getStoragePathFromUXFileInfo(file);
|
||||
const lc = filepath.toLowerCase();
|
||||
if (this.core.shouldCheckCaseInsensitive) {
|
||||
if (this.core.$$shouldCheckCaseInsensitive()) {
|
||||
if (lc in fileCount && fileCount[lc] > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const fileNameLC = getPathFromUXFileInfo(file).split("/").pop()?.toLowerCase();
|
||||
const fileNameLC = getStoragePathFromUXFileInfo(file).split("/").pop()?.toLowerCase();
|
||||
if (this.settings.useIgnoreFiles) {
|
||||
if (this.ignoreFiles.some(e => e.toLowerCase() == fileNameLC)) {
|
||||
if (this.ignoreFiles.some((e) => e.toLowerCase() == fileNameLC)) {
|
||||
// We must reload ignore files due to the its change.
|
||||
await this.readIgnoreFile(filepath);
|
||||
}
|
||||
@@ -104,11 +125,11 @@ export class ModuleTargetFilter extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
}
|
||||
if (!this.localDatabase?.isTargetFile(filepath)) return false;
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
ignoreFileCache = new LRUCache<string, string[] | false>(300, 250000, true);
|
||||
ignoreFiles = [] as string[]
|
||||
ignoreFiles = [] as string[];
|
||||
async readIgnoreFile(path: string) {
|
||||
try {
|
||||
const file = await this.core.storageAccess.readFileText(path);
|
||||
@@ -133,14 +154,14 @@ export class ModuleTargetFilter extends AbstractModule implements ICoreModule {
|
||||
if (!this.settings.useIgnoreFiles) {
|
||||
return false;
|
||||
}
|
||||
const filepath = getPathFromUXFileInfo(file)
|
||||
const filepath = getStoragePathFromUXFileInfo(file);
|
||||
if (this.ignoreFileCache.has(filepath)) {
|
||||
// Renew
|
||||
await this.readIgnoreFile(filepath);
|
||||
}
|
||||
if (!await isAcceptedAll(stripAllPrefixes(filepath), this.ignoreFiles, (filename) => this.getIgnoreFile(filename))) {
|
||||
if (!(await isAcceptedAll(filepath, this.ignoreFiles, (filename) => this.getIgnoreFile(filename)))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { sizeToHumanReadable } from "octagonal-wheels/number";
|
||||
import { delay } from "octagonal-wheels/promises";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
|
||||
export class ModuleCheckRemoteSize extends AbstractModule implements ICoreModule {
|
||||
async $allScanStat(): Promise<boolean> {
|
||||
this._log(`Checking storage sizes`, LOG_LEVEL_VERBOSE);
|
||||
this._log($msg("moduleCheckRemoteSize.logCheckingStorageSizes"), LOG_LEVEL_VERBOSE);
|
||||
if (this.settings.notifyThresholdOfRemoteStorageSize < 0) {
|
||||
const message = `Now, Self-hosted LiveSync is able to check the remote storage size on the start-up.
|
||||
const message = $msg("moduleCheckRemoteSize.msgSetDBCapacity");
|
||||
const ANSWER_0 = $msg("moduleCheckRemoteSize.optionNoWarn");
|
||||
const ANSWER_800 = $msg("moduleCheckRemoteSize.option800MB");
|
||||
const ANSWER_2000 = $msg("moduleCheckRemoteSize.option2GB");
|
||||
const ASK_ME_NEXT_TIME = $msg("moduleCheckRemoteSize.optionAskMeLater");
|
||||
|
||||
You can configure the threshold size for your remote storage. This will be different for your server.
|
||||
|
||||
Please choose the threshold size as you like.
|
||||
|
||||
- 0: Do not warn about storage size.
|
||||
This is recommended if you have enough space on the remote storage especially you have self-hosted. And you can check the storage size and rebuild manually.
|
||||
- 800: Warn if the remote storage size exceeds 800MB.
|
||||
This is recommended if you are using fly.io with 1GB limit or IBM Cloudant.
|
||||
- 2000: Warn if the remote storage size exceeds 2GB.
|
||||
|
||||
And if your actual storage size exceeds the threshold after the setup, you may warned again. But do not worry, you can enlarge the threshold (or rebuild everything to reduce the size).
|
||||
`
|
||||
const ANSWER_0 = "Do not warn";
|
||||
const ANSWER_800 = "800MB";
|
||||
const ANSWER_2000 = "2GB";
|
||||
|
||||
const ret = await this.core.confirm.confirmWithMessage("Remote storage size threshold", message, [ANSWER_0, ANSWER_800, ANSWER_2000], ANSWER_800, 40);
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[ANSWER_0, ANSWER_800, ANSWER_2000, ASK_ME_NEXT_TIME],
|
||||
{
|
||||
defaultAction: ASK_ME_NEXT_TIME,
|
||||
title: $msg("moduleCheckRemoteSize.titleDatabaseSizeNotify"),
|
||||
timeout: 40,
|
||||
}
|
||||
);
|
||||
if (ret == ANSWER_0) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 0;
|
||||
await this.core.saveSettings();
|
||||
} else if (ret == ANSWER_800) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 800;
|
||||
await this.core.saveSettings();
|
||||
} else {
|
||||
} else if (ret == ANSWER_2000) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 2000;
|
||||
await this.core.saveSettings();
|
||||
}
|
||||
@@ -45,46 +41,68 @@ And if your actual storage size exceeds the threshold after the setup, you may w
|
||||
if (estimatedSize) {
|
||||
const maxSize = this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024;
|
||||
if (estimatedSize > maxSize) {
|
||||
const message = `Remote storage size: ${sizeToHumanReadable(estimatedSize)}. It exceeds the configured value ${sizeToHumanReadable(maxSize)}.
|
||||
This may cause the storage to be full. You should enlarge the remote storage, or rebuild everything to reduce the size. \n
|
||||
**Note:** If you are new to Self-hosted LiveSync, you should enlarge the threshold. \n
|
||||
|
||||
Self-hosted LiveSync will not release the storage automatically even if the file is deleted. This is why they need regular maintenance.\n
|
||||
|
||||
If you have enough space on the remote storage, you can enlarge the threshold. Otherwise, you should rebuild everything.\n
|
||||
|
||||
However, **Please make sure that all devices have been synchronised**. \n
|
||||
\n`;
|
||||
const message = $msg("moduleCheckRemoteSize.msgDatabaseGrowing", {
|
||||
estimatedSize: sizeToHumanReadable(estimatedSize),
|
||||
maxSize: sizeToHumanReadable(maxSize),
|
||||
});
|
||||
const newMax = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
const ANSWER_ENLARGE_LIMIT = `Enlarge to ${newMax}MB`;
|
||||
const ANSWER_REBUILD = "Rebuild now";
|
||||
const ANSWER_IGNORE = "Dismiss";
|
||||
const ret = await this.core.confirm.confirmWithMessage("Remote storage size exceeded", message, [ANSWER_ENLARGE_LIMIT, ANSWER_REBUILD, ANSWER_IGNORE,], ANSWER_IGNORE, 20);
|
||||
const ANSWER_ENLARGE_LIMIT = $msg("moduleCheckRemoteSize.optionIncreaseLimit", {
|
||||
newMax: newMax.toString(),
|
||||
});
|
||||
const ANSWER_REBUILD = $msg("moduleCheckRemoteSize.optionRebuildAll");
|
||||
const ANSWER_IGNORE = $msg("moduleCheckRemoteSize.optionDismiss");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[ANSWER_ENLARGE_LIMIT, ANSWER_REBUILD, ANSWER_IGNORE],
|
||||
{
|
||||
defaultAction: ANSWER_IGNORE,
|
||||
title: $msg("moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded"),
|
||||
timeout: 60,
|
||||
}
|
||||
);
|
||||
if (ret == ANSWER_REBUILD) {
|
||||
const ret = await this.core.confirm.askYesNoDialog("This may take a bit of a long time. Do you really want to rebuild everything now?", { defaultOption: "No" });
|
||||
const ret = await this.core.confirm.askYesNoDialog(
|
||||
$msg("moduleCheckRemoteSize.msgConfirmRebuild"),
|
||||
{ defaultOption: "No" }
|
||||
);
|
||||
if (ret == "yes") {
|
||||
this._log(`Receiving all from the server before rebuilding`, LOG_LEVEL_NOTICE);
|
||||
await this.core.$$replicateAllFromServer(true);
|
||||
await delay(3000);
|
||||
this._log(`Obsidian will be reloaded to rebuild everything.`, LOG_LEVEL_NOTICE);
|
||||
this.core.settings.notifyThresholdOfRemoteStorageSize = -1;
|
||||
await this.saveSettings();
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
}
|
||||
} else if (ret == ANSWER_ENLARGE_LIMIT) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
this._log(`Threshold has been enlarged to ${this.settings.notifyThresholdOfRemoteStorageSize}MB`, LOG_LEVEL_NOTICE);
|
||||
this._log(
|
||||
$msg("moduleCheckRemoteSize.logThresholdEnlarged", {
|
||||
size: this.settings.notifyThresholdOfRemoteStorageSize.toString(),
|
||||
}),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await this.core.saveSettings();
|
||||
} else {
|
||||
// Dismiss or Close the dialog
|
||||
}
|
||||
|
||||
this._log(`Remote storage size: ${sizeToHumanReadable(estimatedSize)} exceeded ${sizeToHumanReadable(this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024)} `, LOG_LEVEL_INFO);
|
||||
this._log(
|
||||
$msg("moduleCheckRemoteSize.logExceededWarning", {
|
||||
measuredSize: sizeToHumanReadable(estimatedSize),
|
||||
notifySize: sizeToHumanReadable(
|
||||
this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024
|
||||
),
|
||||
}),
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
this._log(`Remote storage size: ${sizeToHumanReadable(estimatedSize)}`, LOG_LEVEL_INFO);
|
||||
this._log(
|
||||
$msg("moduleCheckRemoteSize.logCurrentStorageSize", {
|
||||
measuredSize: sizeToHumanReadable(estimatedSize),
|
||||
}),
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { sendValue } from "octagonal-wheels/messagepassing/signal";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleConflictChecker extends AbstractModule implements ICoreModule {
|
||||
|
||||
async $$queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> {
|
||||
const path = file;
|
||||
if (this.settings.checkConflictOnlyOnOpen) {
|
||||
@@ -36,40 +35,44 @@ export class ModuleConflictChecker extends AbstractModule implements ICoreModule
|
||||
}
|
||||
|
||||
// TODO-> Move to ModuleConflictResolver?
|
||||
conflictResolveQueue = new QueueProcessor(async (filenames: FilePathWithPrefix[]) => {
|
||||
await this.core.$$resolveConflict(filenames[0]);
|
||||
}, {
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 1,
|
||||
delay: 10,
|
||||
keepResultUntilDownstreamConnected: false
|
||||
}).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
conflictResolveQueue = new QueueProcessor(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
await this.core.$$resolveConflict(filenames[0]);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 1,
|
||||
delay: 10,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const filename = newEntity;
|
||||
sendValue("cancel-resolve-conflict:" + filename, true);
|
||||
const newQueue = [...queue].filter(e => e != newEntity);
|
||||
const newQueue = [...queue].filter((e) => e != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
|
||||
conflictCheckQueue = // First process - Check is the file actually need resolve -
|
||||
new QueueProcessor((files: FilePathWithPrefix[]) => {
|
||||
const filename = files[0];
|
||||
// const file = await this.core.storageAccess.isExists(filename);
|
||||
// if (!file) return [];
|
||||
// if (!(file instanceof TFile)) return;
|
||||
// if ((file instanceof TFolder)) return [];
|
||||
// Check again?
|
||||
return Promise.resolve([filename]);
|
||||
// this.conflictResolveQueue.enqueueWithKey(filename, { filename, file });
|
||||
}, {
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 5,
|
||||
delay: 10,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
pipeTo: this.conflictResolveQueue,
|
||||
totalRemainingReactiveSource: this.core.conflictProcessQueueCount
|
||||
});
|
||||
|
||||
}
|
||||
new QueueProcessor(
|
||||
(files: FilePathWithPrefix[]) => {
|
||||
const filename = files[0];
|
||||
// const file = await this.core.storageAccess.isExists(filename);
|
||||
// if (!file) return [];
|
||||
// if (!(file instanceof TFile)) return;
|
||||
// if ((file instanceof TFolder)) return [];
|
||||
// Check again?
|
||||
return Promise.resolve([filename]);
|
||||
// this.conflictResolveQueue.enqueueWithKey(filename, { filename, file });
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 5,
|
||||
delay: 10,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
pipeTo: this.conflictResolveQueue,
|
||||
totalRemainingReactiveSource: this.core.conflictProcessQueueCount,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,71 @@
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { AUTO_MERGED, CANCELLED, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, MISSING_OR_ERROR, NOT_CONFLICTED, type diff_check_result, type FilePathWithPrefix } from "../../lib/src/common/types";
|
||||
import { compareMTime, displayRev, TARGET_IS_NEW } from "../../common/utils";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type diff_check_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "../../lib/src/common/types";
|
||||
import {
|
||||
compareMTime,
|
||||
displayRev,
|
||||
isCustomisationSyncMetadata,
|
||||
isPluginMetadata,
|
||||
TARGET_IS_NEW,
|
||||
} from "../../common/utils";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "../../lib/src/string_and_binary/path";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
import { eventHub } from "../../common/events.ts";
|
||||
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
"conflict-cancelled": FilePathWithPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
export class ModuleConflictResolver extends AbstractModule implements ICoreModule {
|
||||
|
||||
async $$resolveConflictByDeletingRev(path: FilePathWithPrefix, deleteRevision: string, subTitle = ""): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
|
||||
async $$resolveConflictByDeletingRev(
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = ""
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision)) {
|
||||
this._log(`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`, LOG_LEVEL_NOTICE);
|
||||
if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
this._log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
this._log(`${title} Conflicted revision deleted ${displayRev(deleteRevision)} ${path}`, LOG_LEVEL_INFO);
|
||||
eventHub.emitEvent("conflict-cancelled", path);
|
||||
this._log(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
if ((await this.core.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
this._log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
|
||||
this._log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!await this.core.fileHandler.dbToStorage(path, stripAllPrefixes(path), true)) {
|
||||
if (!(await this.core.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
this._log(`${path} Has been merged automatically`, LOG_LEVEL_NOTICE);
|
||||
const level = subTitle.indexOf("same") !== -1 ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
|
||||
this._log(`${path} has been merged automatically`, level);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
|
||||
|
||||
async checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise<diff_check_result> {
|
||||
//
|
||||
const ret = await this.localDatabase.tryAutoMerge(path, !this.settings.disableMarkdownAutoMerge);
|
||||
@@ -40,7 +77,7 @@ export class ModuleConflictResolver extends AbstractModule implements ICoreModul
|
||||
const p = ret.result;
|
||||
// Merged content is coming.
|
||||
// 1. Store the merged content to the storage
|
||||
if (!await this.core.databaseFileAccess.storeContent(path, p)) {
|
||||
if (!(await this.core.databaseFileAccess.storeContent(path, p))) {
|
||||
this._log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
@@ -65,13 +102,19 @@ export class ModuleConflictResolver extends AbstractModule implements ICoreModul
|
||||
const isBinary = !isPlainText(path);
|
||||
const alwaysNewer = this.settings.resolveConflictsByNewerFile;
|
||||
if (isSame || isBinary || alwaysNewer) {
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime)
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
|
||||
let loser = leftLeaf;
|
||||
// if (lMtime > rMtime) {
|
||||
if (result != TARGET_IS_NEW) {
|
||||
loser = rightLeaf;
|
||||
}
|
||||
const subTitle = [`${isSame ? "same" : ""}`, `${isBinary ? "binary" : ""}`, `${alwaysNewer ? "alwaysNewer" : ""}`].join(",");
|
||||
const subTitle = [
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
]
|
||||
.filter((e) => e.trim())
|
||||
.join(",");
|
||||
return await this.core.$$resolveConflictByDeletingRev(path, loser.rev, subTitle);
|
||||
}
|
||||
// make diff.
|
||||
@@ -86,56 +129,85 @@ export class ModuleConflictResolver extends AbstractModule implements ICoreModul
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
async $$resolveConflict(filename: FilePathWithPrefix): Promise<void> {
|
||||
// const filename = filenames[0];
|
||||
return await serialized(`conflict-resolve:${filename}`, async () => {
|
||||
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
|
||||
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === NOT_CONFLICTED || conflictCheckResult === CANCELLED) {
|
||||
if (
|
||||
conflictCheckResult === MISSING_OR_ERROR ||
|
||||
conflictCheckResult === NOT_CONFLICTED ||
|
||||
conflictCheckResult === CANCELLED
|
||||
) {
|
||||
// nothing to do.
|
||||
this._log(`conflict:Nothing to do:${filename}`);
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === AUTO_MERGED) {
|
||||
//auto resolved, but need check again;
|
||||
if (this.settings.syncAfterMerge && !this.core.suspended) {
|
||||
if (this.settings.syncAfterMerge && !this.core.$$isSuspended()) {
|
||||
//Wait for the running replication, if not running replication, run it once.
|
||||
await this.core.$$waitForReplicationOnce();
|
||||
await this.core.$$replicateByEvent();
|
||||
}
|
||||
this._log("conflict:Automatically merged, but we have to check it again");
|
||||
this._log("[conflict] Automatically merged, but we have to check it again");
|
||||
await this.core.$$queueConflictCheck(filename);
|
||||
return;
|
||||
}
|
||||
if (this.settings.showMergeDialogOnlyOnActive) {
|
||||
const af = this.core.$$getActiveFilePath();
|
||||
if (af && af != filename) {
|
||||
this._log(`${filename} is conflicted. Merging process has been postponed to the file have got opened.`, LOG_LEVEL_NOTICE);
|
||||
this._log(
|
||||
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._log("conflict:Manual merge required!");
|
||||
this._log("[conflict] Manual merge required!");
|
||||
eventHub.emitEvent("conflict-cancelled", filename);
|
||||
await this.core.$anyResolveConflictByUI(filename, conflictCheckResult);
|
||||
});
|
||||
}
|
||||
|
||||
async $anyResolveConflictByNewest(filename: FilePathWithPrefix): Promise<boolean> {
|
||||
const currentRev = await this.core.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||
if (currentRev == false) {
|
||||
this._log(`Could not get current revision of ${filename}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const revs = await this.core.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const mTimeAndRev = (await Promise.all(revs.map(async (rev) => {
|
||||
const leaf = await this.core.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev] as [number, string];
|
||||
const mTimeAndRev = (
|
||||
[
|
||||
[currentRev.mtime, currentRev._rev],
|
||||
...(await Promise.all(
|
||||
revs.map(async (rev) => {
|
||||
const leaf = await this.core.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev] as [number, string];
|
||||
}
|
||||
return [leaf.mtime, rev] as [number, string];
|
||||
})
|
||||
)),
|
||||
] as [number, string][]
|
||||
).sort((a, b) => {
|
||||
const diff = b[0] - a[0];
|
||||
if (diff == 0) {
|
||||
return a[1].localeCompare(b[1], "en", { numeric: true });
|
||||
}
|
||||
return [leaf.mtime, rev] as [number, string];
|
||||
}))).sort((a, b) => b[0] - a[0]);
|
||||
this._log(`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`);
|
||||
return diff;
|
||||
});
|
||||
// console.warn(mTimeAndRev);
|
||||
this._log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||
this._log(`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`);
|
||||
this._log(
|
||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||
);
|
||||
await this.core.$$resolveConflictByDeletingRev(filename, mTimeAndRev[i][1], "NEWEST");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { normalizePath } from "../../deps.ts";
|
||||
import { FLAGMD_REDFLAG, FLAGMD_REDFLAG2, FLAGMD_REDFLAG2_HR, FLAGMD_REDFLAG3, FLAGMD_REDFLAG3_HR } from "../../lib/src/common/types.ts";
|
||||
import {
|
||||
FLAGMD_REDFLAG,
|
||||
FLAGMD_REDFLAG2,
|
||||
FLAGMD_REDFLAG2_HR,
|
||||
FLAGMD_REDFLAG3,
|
||||
FLAGMD_REDFLAG3_HR,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
||||
|
||||
export class ModuleRedFlag extends AbstractModule implements ICoreModule {
|
||||
|
||||
async isFlagFileExist(path: string) {
|
||||
const redflag = await this.core.storageAccess.isExists(normalizePath(path));
|
||||
if (redflag) {
|
||||
@@ -26,9 +32,11 @@ export class ModuleRedFlag extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
}
|
||||
|
||||
isRedFlagRaised = async () => await this.isFlagFileExist(FLAGMD_REDFLAG)
|
||||
isRedFlag2Raised = async () => await this.isFlagFileExist(FLAGMD_REDFLAG2) || await this.isFlagFileExist(FLAGMD_REDFLAG2_HR)
|
||||
isRedFlag3Raised = async () => await this.isFlagFileExist(FLAGMD_REDFLAG3) || await this.isFlagFileExist(FLAGMD_REDFLAG3_HR)
|
||||
isRedFlagRaised = async () => await this.isFlagFileExist(FLAGMD_REDFLAG);
|
||||
isRedFlag2Raised = async () =>
|
||||
(await this.isFlagFileExist(FLAGMD_REDFLAG2)) || (await this.isFlagFileExist(FLAGMD_REDFLAG2_HR));
|
||||
isRedFlag3Raised = async () =>
|
||||
(await this.isFlagFileExist(FLAGMD_REDFLAG3)) || (await this.isFlagFileExist(FLAGMD_REDFLAG3_HR));
|
||||
|
||||
async deleteRedFlag2() {
|
||||
await this.deleteFlagFile(FLAGMD_REDFLAG2);
|
||||
@@ -47,14 +55,24 @@ export class ModuleRedFlag extends AbstractModule implements ICoreModule {
|
||||
|
||||
if (isRedFlagRaised || isRedFlag2Raised || isRedFlag3Raised) {
|
||||
if (isRedFlag2Raised) {
|
||||
if (await this.core.confirm.askYesNoDialog("Rebuild everything has been scheduled! Are you sure to rebuild everything?", { defaultOption: "Yes", timeout: 0 }) !== "yes") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Rebuild everything has been scheduled! Are you sure to rebuild everything?",
|
||||
{ defaultOption: "Yes", timeout: 0 }
|
||||
)) !== "yes"
|
||||
) {
|
||||
await this.deleteRedFlag2();
|
||||
await this.core.$$performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isRedFlag3Raised) {
|
||||
if (await this.core.confirm.askYesNoDialog("Fetch again has been scheduled! Are you sure?", { defaultOption: "Yes", timeout: 0 }) !== "yes") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Fetch again has been scheduled! Are you sure?", {
|
||||
defaultOption: "Yes",
|
||||
timeout: 0,
|
||||
})) !== "yes"
|
||||
) {
|
||||
await this.deleteRedFlag3();
|
||||
await this.core.$$performRestart();
|
||||
return false;
|
||||
@@ -66,33 +84,82 @@ export class ModuleRedFlag extends AbstractModule implements ICoreModule {
|
||||
this.settings.suspendFileWatching = true;
|
||||
await this.saveSettings();
|
||||
if (isRedFlag2Raised) {
|
||||
this._log(`${FLAGMD_REDFLAG2} or ${FLAGMD_REDFLAG2_HR} has been detected! Self-hosted LiveSync suspends all sync and rebuild everything.`, LOG_LEVEL_NOTICE);
|
||||
this._log(
|
||||
`${FLAGMD_REDFLAG2} or ${FLAGMD_REDFLAG2_HR} has been detected! Self-hosted LiveSync suspends all sync and rebuild everything.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await this.core.rebuilder.$rebuildEverything();
|
||||
await this.deleteRedFlag2();
|
||||
if (await this.core.confirm.askYesNoDialog("Do you want to resume file and database processing, and restart obsidian now?", { defaultOption: "Yes", timeout: 15 }) == "yes") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) == "yes"
|
||||
) {
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
this.core.$$performRestart();
|
||||
return false;
|
||||
}
|
||||
} else if (isRedFlag3Raised) {
|
||||
this._log(`${FLAGMD_REDFLAG3} or ${FLAGMD_REDFLAG3_HR} has been detected! Self-hosted LiveSync will discard the local database and fetch everything from the remote once again.`, LOG_LEVEL_NOTICE);
|
||||
const makeLocalChunkBeforeSync = ((await this.core.confirm.askYesNoDialog("Do you want to create local chunks before fetching?", { defaultOption: "Yes" })) == "yes");
|
||||
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync);
|
||||
this._log(
|
||||
`${FLAGMD_REDFLAG3} or ${FLAGMD_REDFLAG3_HR} has been detected! Self-hosted LiveSync will discard the local database and fetch everything from the remote once again.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const method1 = $msg("RedFlag.Fetch.Method.FetchSafer");
|
||||
const method2 = $msg("RedFlag.Fetch.Method.FetchSmoother");
|
||||
const method3 = $msg("RedFlag.Fetch.Method.FetchTraditional");
|
||||
|
||||
const methods = [method1, method2, method3] as const;
|
||||
const chunkMode = await this.core.confirm.askSelectStringDialogue(
|
||||
$msg("RedFlag.Fetch.Method.Desc"),
|
||||
methods,
|
||||
{
|
||||
defaultAction: method1,
|
||||
timeout: 0,
|
||||
title: $msg("RedFlag.Fetch.Method.Title"),
|
||||
}
|
||||
);
|
||||
let makeLocalChunkBeforeSync = false;
|
||||
let preventMakeLocalFilesBeforeSync = false;
|
||||
if (chunkMode === method1) {
|
||||
preventMakeLocalFilesBeforeSync = true;
|
||||
} else if (chunkMode === method2) {
|
||||
makeLocalChunkBeforeSync = true;
|
||||
} else if (chunkMode === method3) {
|
||||
// Do nothing.
|
||||
} else {
|
||||
this._log("Cancelled the fetch operation", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, preventMakeLocalFilesBeforeSync);
|
||||
|
||||
await this.deleteRedFlag3();
|
||||
if (this.settings.suspendFileWatching) {
|
||||
if (await this.core.confirm.askYesNoDialog("Do you want to resume file and database processing, and restart obsidian now?", { defaultOption: "Yes", timeout: 15 }) == "yes") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) == "yes"
|
||||
) {
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
this.core.$$performRestart();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
this._log(
|
||||
"Your content of files will be synchronised gradually. Please wait for the completion.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Case of FLAGMD_REDFLAG.
|
||||
this.settings.writeLogToTheFile = true;
|
||||
// await this.plugin.openDatabase();
|
||||
const warningMessage = "The red flag is raised! The whole initialize steps are skipped, and any file changes are not captured.";
|
||||
const warningMessage =
|
||||
"The red flag is raised! The whole initialize steps are skipped, and any file changes are not captured.";
|
||||
this._log(warningMessage, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
@@ -102,5 +169,4 @@ export class ModuleRedFlag extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ export class ModuleRemoteGovernor extends AbstractModule implements ICoreModule
|
||||
async $$markRemoteResolved(): Promise<void> {
|
||||
return await this.core.replicator.markRemoteResolved(this.settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,154 @@
|
||||
import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { TweakValuesShouldMatchedTemplate, CompatibilityBreakingTweakValues, confName, type TweakValues } from "../../lib/src/common/types.ts";
|
||||
import {
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
IncompatibleChanges,
|
||||
confName,
|
||||
type TweakValues,
|
||||
type RemoteDBSettings,
|
||||
IncompatibleChangesInSpecificPattern,
|
||||
CompatibleButLossyChanges,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { escapeMarkdownValue } from "../../lib/src/common/utils.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule implements ICoreModule {
|
||||
async $anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched) return false;
|
||||
const ret = await this.core.$$askResolvingMismatchedTweaks();
|
||||
if (!this.core.replicator.tweakSettingsMismatched && !this.core.replicator.preferredTweakValue) return false;
|
||||
const preferred = this.core.replicator.preferredTweakValue;
|
||||
if (!preferred) return false;
|
||||
const ret = await this.core.$$askResolvingMismatchedTweaks(preferred);
|
||||
if (ret == "OK") return false;
|
||||
if (ret == "CHECKAGAIN") return "CHECKAGAIN";
|
||||
if (ret == "IGNORE") return true;
|
||||
}
|
||||
|
||||
async $$askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched) {
|
||||
return "OK";
|
||||
}
|
||||
const preferred = extractObject(TweakValuesShouldMatchedTemplate, this.core.replicator.preferredTweakValue!);
|
||||
async $$checkAndAskResolvingMismatchedTweaks(
|
||||
preferred: Partial<TweakValues>
|
||||
): Promise<[TweakValues | boolean, boolean]> {
|
||||
const mine = extractObject(TweakValuesShouldMatchedTemplate, this.settings);
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
|
||||
let rebuildRecommended = false;
|
||||
// Making tables:
|
||||
let table = `| Value name | This device | Configured | \n` + `|: --- |: --- :|: ---- :| \n`;
|
||||
|
||||
// let table = `| Value name | This device | Configured | \n` + `|: --- |: --- :|: ---- :| \n`;
|
||||
const tableRows = [];
|
||||
// const items = [mine,preferred]
|
||||
for (const v of items) {
|
||||
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
|
||||
const valueMine = escapeMarkdownValue(mine[key]);
|
||||
const valuePreferred = escapeMarkdownValue(preferred[key]);
|
||||
if (valueMine == valuePreferred) continue;
|
||||
if (CompatibilityBreakingTweakValues.indexOf(key) !== -1) {
|
||||
if (IncompatibleChanges.indexOf(key) !== -1) {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
table += `| ${confName(key)} | ${valueMine} | ${valuePreferred} | \n`;
|
||||
for (const pattern of IncompatibleChangesInSpecificPattern) {
|
||||
if (pattern.key !== key) continue;
|
||||
// if from value supplied, check if current value have been violated : in other words, if the current value is the same as the from value, it should require a rebuild.
|
||||
const isFromConditionMet = "from" in pattern ? pattern.from === mine[key] : false;
|
||||
// and, if to value supplied, same as above.
|
||||
const isToConditionMet = "to" in pattern ? pattern.to === preferred[key] : false;
|
||||
// if either of them is true, it should require a rebuild, if the pattern is not a recommendation.
|
||||
if (isFromConditionMet || isToConditionMet) {
|
||||
if (pattern.isRecommendation) {
|
||||
rebuildRecommended = true;
|
||||
} else {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (CompatibleButLossyChanges.indexOf(key) !== -1) {
|
||||
rebuildRecommended = true;
|
||||
}
|
||||
|
||||
// table += `| ${confName(key)} | ${valueMine} | ${valuePreferred} | \n`;
|
||||
tableRows.push(
|
||||
$msg("TweakMismatchResolve.Table.Row", {
|
||||
name: confName(key),
|
||||
self: valueMine,
|
||||
remote: valuePreferred,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const additionalMessage = rebuildRequired ? `
|
||||
const additionalMessage =
|
||||
rebuildRequired && this.core.settings.isConfigured
|
||||
? $msg("TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired")
|
||||
: "";
|
||||
const additionalMessage2 =
|
||||
rebuildRecommended && this.core.settings.isConfigured
|
||||
? $msg("TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended")
|
||||
: "";
|
||||
|
||||
**Note**: We have detected that some of the values are different to make incompatible the local database with the remote database.
|
||||
If you choose to use the configured values, the local database will be rebuilt, and if you choose to use the values of this device, the remote database will be rebuilt.
|
||||
Both of them takes a few minutes. Please choose after considering the situation.` : "";
|
||||
const table = $msg("TweakMismatchResolve.Table", { rows: tableRows.join("\n") });
|
||||
|
||||
const message = `
|
||||
Your configuration has not been matched with the one on the remote server.
|
||||
(Which you had decided once before, or set by initially synchronised device).
|
||||
const message = $msg("TweakMismatchResolve.Message.MainTweakResolving", {
|
||||
table: table,
|
||||
additionalMessage: [additionalMessage, additionalMessage2].filter((v) => v).join("\n"),
|
||||
});
|
||||
|
||||
Configured values:
|
||||
const CHOICE_USE_REMOTE = $msg("TweakMismatchResolve.Action.UseRemote");
|
||||
const CHOICE_USE_REMOTE_WITH_REBUILD = $msg("TweakMismatchResolve.Action.UseRemoteWithRebuild");
|
||||
const CHOICE_USE_REMOTE_PREVENT_REBUILD = $msg("TweakMismatchResolve.Action.UseRemoteAcceptIncompatible");
|
||||
const CHOICE_USE_MINE = $msg("TweakMismatchResolve.Action.UseMine");
|
||||
const CHOICE_USE_MINE_WITH_REBUILD = $msg("TweakMismatchResolve.Action.UseMineWithRebuild");
|
||||
const CHOICE_USE_MINE_PREVENT_REBUILD = $msg("TweakMismatchResolve.Action.UseMineAcceptIncompatible");
|
||||
const CHOICE_DISMISS = $msg("TweakMismatchResolve.Action.Dismiss");
|
||||
|
||||
${table}
|
||||
const CHOICE_AND_VALUES = [] as [string, [result: TweakValues | boolean, rebuild: boolean]][];
|
||||
|
||||
Please select which one you want to use.
|
||||
if (rebuildRequired) {
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_PREVENT_REBUILD, [preferred, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_PREVENT_REBUILD, [true, false]]);
|
||||
} else if (rebuildRecommended) {
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [true, true]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
|
||||
} else {
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
|
||||
}
|
||||
CHOICE_AND_VALUES.push([CHOICE_DISMISS, [false, false]]);
|
||||
const CHOICES = Object.fromEntries(CHOICE_AND_VALUES) as Record<
|
||||
string,
|
||||
[TweakValues | boolean, performRebuild: boolean]
|
||||
>;
|
||||
const retKey = await this.core.confirm.askSelectStringDialogue(message, Object.keys(CHOICES), {
|
||||
title: $msg("TweakMismatchResolve.Title.TweakResolving"),
|
||||
timeout: 60,
|
||||
defaultAction: CHOICE_DISMISS,
|
||||
});
|
||||
if (!retKey) return [false, false];
|
||||
return CHOICES[retKey];
|
||||
}
|
||||
|
||||
- Use configured: Update settings of this device by configured one on the remote server.
|
||||
You should select this if you have changed the settings on ** another device **.
|
||||
- Update with mine: Update settings on the remote server by the settings of this device.
|
||||
You should select this if you have changed the settings on ** this device **.
|
||||
- Dismiss: Ignore this message and keep the current settings.
|
||||
You cannot synchronise until you resolve this issue without enabling \`Do not check configuration mismatch before replication\`.${additionalMessage}`;
|
||||
async $$askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched) {
|
||||
return "OK";
|
||||
}
|
||||
const tweaks = this.core.replicator.preferredTweakValue;
|
||||
if (!tweaks) {
|
||||
return "IGNORE";
|
||||
}
|
||||
const preferred = extractObject(TweakValuesShouldMatchedTemplate, tweaks);
|
||||
|
||||
const CHOICE_USE_REMOTE = "Use configured";
|
||||
const CHOICE_USR_MINE = "Update with mine";
|
||||
const CHOICE_DISMISS = "Dismiss";
|
||||
const CHOICE_AND_VALUES = [
|
||||
[CHOICE_USE_REMOTE, preferred],
|
||||
[CHOICE_USR_MINE, true],
|
||||
[CHOICE_DISMISS, false]]
|
||||
const CHOICES = Object.fromEntries(CHOICE_AND_VALUES) as Record<string, TweakValues | boolean>;
|
||||
const retKey = await this.core.confirm.confirmWithMessage("Tweaks Mismatched or Changed", message, Object.keys(CHOICES), CHOICE_DISMISS, 60);
|
||||
if (!retKey) return "IGNORE";
|
||||
const conf = CHOICES[retKey];
|
||||
const [conf, rebuildRequired] = await this.core.$$checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
if (!conf) return "IGNORE";
|
||||
|
||||
if (conf === true) {
|
||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$rebuildRemote();
|
||||
}
|
||||
Logger(`Tweak values on the remote server have been updated. Your other device will see this message.`, LOG_LEVEL_NOTICE);
|
||||
Logger(
|
||||
`Tweak values on the remote server have been updated. Your other device will see this message.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
if (conf) {
|
||||
@@ -92,6 +162,114 @@ Please select which one you want to use.
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
return "IGNORE";
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
async $$checkAndAskUseRemoteConfiguration(
|
||||
trialSetting: RemoteDBSettings
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
const replicator = await this.core.$anyNewReplicator(trialSetting);
|
||||
if (await replicator.tryConnectRemote(trialSetting)) {
|
||||
const preferred = await replicator.getRemotePreferredTweakValues(trialSetting);
|
||||
if (preferred) {
|
||||
return await this.$$askUseRemoteConfiguration(trialSetting, preferred);
|
||||
} else {
|
||||
this._log("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return { result: false, requireFetch: false };
|
||||
} else {
|
||||
this._log("Failed to connect to the remote server.", LOG_LEVEL_NOTICE);
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
}
|
||||
|
||||
async $$askUseRemoteConfiguration(
|
||||
trialSetting: RemoteDBSettings,
|
||||
preferred: TweakValues
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
let rebuildRecommended = false;
|
||||
// Making tables:
|
||||
// let table = `| Value name | This device | On Remote | \n` + `|: --- |: ---- :|: ---- :| \n`;
|
||||
let differenceCount = 0;
|
||||
const tableRows = [] as string[];
|
||||
// const items = [mine,preferred]
|
||||
for (const v of items) {
|
||||
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
|
||||
const remoteValueForDisplay = escapeMarkdownValue(preferred[key]);
|
||||
const currentValueForDisplay = `${escapeMarkdownValue((trialSetting as TweakValues)?.[key])}`;
|
||||
if ((trialSetting as TweakValues)?.[key] !== preferred[key]) {
|
||||
if (IncompatibleChanges.indexOf(key) !== -1) {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
for (const pattern of IncompatibleChangesInSpecificPattern) {
|
||||
if (pattern.key !== key) continue;
|
||||
// if from value supplied, check if current value have been violated : in other words, if the current value is the same as the from value, it should require a rebuild.
|
||||
const isFromConditionMet =
|
||||
"from" in pattern ? pattern.from === (trialSetting as TweakValues)?.[key] : false;
|
||||
// and, if to value supplied, same as above.
|
||||
const isToConditionMet = "to" in pattern ? pattern.to === preferred[key] : false;
|
||||
// if either of them is true, it should require a rebuild, if the pattern is not a recommendation.
|
||||
if (isFromConditionMet || isToConditionMet) {
|
||||
if (pattern.isRecommendation) {
|
||||
rebuildRecommended = true;
|
||||
} else {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (CompatibleButLossyChanges.indexOf(key) !== -1) {
|
||||
rebuildRecommended = true;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
tableRows.push(
|
||||
$msg("TweakMismatchResolve.Table.Row", {
|
||||
name: confName(key),
|
||||
self: currentValueForDisplay,
|
||||
remote: remoteValueForDisplay,
|
||||
})
|
||||
);
|
||||
differenceCount++;
|
||||
}
|
||||
|
||||
if (differenceCount === 0) {
|
||||
this._log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
const additionalMessage =
|
||||
rebuildRequired && this.core.settings.isConfigured
|
||||
? $msg("TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired")
|
||||
: "";
|
||||
const additionalMessage2 =
|
||||
rebuildRecommended && this.core.settings.isConfigured
|
||||
? $msg("TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended")
|
||||
: "";
|
||||
|
||||
const table = $msg("TweakMismatchResolve.Table", { rows: tableRows.join("\n") });
|
||||
|
||||
const message = $msg("TweakMismatchResolve.Message.Main", {
|
||||
table: table,
|
||||
additionalMessage: [additionalMessage, additionalMessage2].filter((v) => v).join("\n"),
|
||||
});
|
||||
|
||||
const CHOICE_USE_REMOTE = $msg("TweakMismatchResolve.Action.UseConfigured");
|
||||
const CHOICE_DISMISS = $msg("TweakMismatchResolve.Action.Dismiss");
|
||||
// const CHOICE_AND_VALUES = [
|
||||
// [CHOICE_USE_REMOTE, preferred],
|
||||
// [CHOICE_DISMISS, false]]
|
||||
const CHOICES = [CHOICE_USE_REMOTE, CHOICE_DISMISS];
|
||||
const retKey = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
|
||||
title: $msg("TweakMismatchResolve.Title.UseRemoteConfig"),
|
||||
timeout: 0,
|
||||
defaultAction: CHOICE_DISMISS,
|
||||
});
|
||||
if (!retKey) return { result: false, requireFetch: false };
|
||||
if (retKey === CHOICE_DISMISS) return { result: false, requireFetch: false };
|
||||
if (retKey === CHOICE_USE_REMOTE) {
|
||||
return { result: { ...trialSetting, ...preferred }, requireFetch: rebuildRequired };
|
||||
}
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { normalizePath, TFile, TFolder, type ListedFiles } from "obsidian";
|
||||
import { TFile, TFolder, type ListedFiles } from "obsidian";
|
||||
import { SerializedFileAccess } from "./storageLib/SerializedFileAccess";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import type { FilePath, FilePathWithPrefix, UXDataWriteOptions, UXFileInfo, UXFileInfoStub, UXFolderInfo, UXStat } from "../../lib/src/common/types";
|
||||
import type {
|
||||
FilePath,
|
||||
FilePathWithPrefix,
|
||||
UXDataWriteOptions,
|
||||
UXFileInfo,
|
||||
UXFileInfoStub,
|
||||
UXFolderInfo,
|
||||
UXStat,
|
||||
} from "../../lib/src/common/types";
|
||||
import { TFileToUXFileInfoStub, TFolderToUXFileInfoStub } from "./storageLib/utilObsidian.ts";
|
||||
import { StorageEventManagerObsidian, type StorageEventManager } from "./storageLib/StorageEventManager";
|
||||
import type { StorageAccess } from "../interfaces/StorageAccess";
|
||||
import { createBlob } from "../../lib/src/common/utils";
|
||||
|
||||
|
||||
export class ModuleFileAccessObsidian extends AbstractObsidianModule implements IObsidianModule, StorageAccess {
|
||||
vaultAccess!: SerializedFileAccess;
|
||||
vaultManager: StorageEventManager = new StorageEventManagerObsidian(this.plugin, this.core);
|
||||
@@ -43,12 +50,33 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
return this.vaultAccess.isStorageInsensitive();
|
||||
}
|
||||
|
||||
$$shouldCheckCaseInsensitive(): boolean {
|
||||
if (this.$$isStorageInsensitive()) return false;
|
||||
return !this.settings.handleFilenameCaseSensitive;
|
||||
}
|
||||
|
||||
async writeFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise<boolean> {
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
return this.vaultAccess.vaultModify(file, data, opt);
|
||||
} else if (file === null) {
|
||||
return await this.vaultAccess.vaultCreate(path, data, opt) instanceof TFile;
|
||||
if (!path.endsWith(".md")) {
|
||||
// Very rare case, we encountered this case with `writing-goals-history.csv` file.
|
||||
// Indeed, that file not appears in the File Explorer, but it exists in the vault.
|
||||
// Hence, we cannot retrieve the file from the vault by getAbstractFileByPath, and we cannot write it via vaultModify.
|
||||
// It makes `File already exists` error.
|
||||
// Therefore, we need to write it via adapterWrite.
|
||||
// Maybe there are others like this, so I will write it via adapterWrite.
|
||||
// This is a workaround for the issue, but I don't know if this is the right solution.
|
||||
// (So limits to non-md files).
|
||||
// Has Obsidian been patched?, anyway, writing directly might be a safer approach.
|
||||
// However, does changes of that file trigger file-change event?
|
||||
await this.vaultAccess.adapterWrite(path, data, opt);
|
||||
// For safety, check existence
|
||||
return await this.vaultAccess.adapterExists(path);
|
||||
} else {
|
||||
return (await this.vaultAccess.vaultCreate(path, data, opt)) instanceof TFile;
|
||||
}
|
||||
} else {
|
||||
this._log(`Could not write file (Possibly already exists as a folder): ${path}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
@@ -85,7 +113,6 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
}
|
||||
async appendHiddenFile(path: string, data: string, opt?: UXDataWriteOptions): Promise<boolean> {
|
||||
try {
|
||||
|
||||
await this.vaultAccess.adapterAppend(path, data, opt);
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -102,19 +129,21 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
ctime: file.stat.ctime,
|
||||
mtime: file.stat.mtime,
|
||||
size: file.stat.size,
|
||||
type: "file"
|
||||
type: "file",
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Could not stat file (Possibly does not exist): ${path}`);
|
||||
}
|
||||
|
||||
}
|
||||
statHidden(path: string): Promise<UXStat | null> {
|
||||
return this.vaultAccess.adapterStat(path);
|
||||
return this.vaultAccess.tryAdapterStat(path);
|
||||
}
|
||||
async removeHidden(path: string): Promise<boolean> {
|
||||
try {
|
||||
await this.vaultAccess.adapterRemove(path);
|
||||
if (this.vaultAccess.tryAdapterStat(path) !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._log(`Could not remove hidden file: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
@@ -132,7 +161,7 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
return await this.vaultAccess.adapterReadBinary(path);
|
||||
}
|
||||
async isExistsIncludeHidden(path: string): Promise<boolean> {
|
||||
return await this.vaultAccess.adapterStat(path) !== null;
|
||||
return (await this.vaultAccess.tryAdapterStat(path)) !== null;
|
||||
}
|
||||
async ensureDir(path: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -145,8 +174,9 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
}
|
||||
}
|
||||
triggerFileEvent(event: string, path: string): void {
|
||||
// this.app.vault.trigger("file-change", path);
|
||||
this.vaultAccess.trigger(event, this.vaultAccess.getAbstractFileByPath(normalizePath(path)));
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
if (file === null) return;
|
||||
this.vaultAccess.trigger(event, file);
|
||||
}
|
||||
async triggerHiddenFile(path: string): Promise<void> {
|
||||
//@ts-ignore internal function
|
||||
@@ -172,8 +202,8 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
const data = await this.vaultAccess.vaultReadAuto(file);
|
||||
return {
|
||||
...stub,
|
||||
body: createBlob(data)
|
||||
}
|
||||
body: createBlob(data),
|
||||
};
|
||||
}
|
||||
getStub(path: string): UXFileInfoStub | UXFolderInfo | null {
|
||||
const file = this.vaultAccess.getAbstractFileByPath(path);
|
||||
@@ -185,10 +215,10 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
return null;
|
||||
}
|
||||
getFiles(): UXFileInfoStub[] {
|
||||
return this.vaultAccess.getFiles().map(f => TFileToUXFileInfoStub(f));
|
||||
return this.vaultAccess.getFiles().map((f) => TFileToUXFileInfoStub(f));
|
||||
}
|
||||
getFileNames(): FilePath[] {
|
||||
return this.vaultAccess.getFiles().map(f => f.path as FilePath);
|
||||
return this.vaultAccess.getFiles().map((f) => f.path as FilePath);
|
||||
}
|
||||
|
||||
async getFilesIncludeHidden(
|
||||
@@ -205,20 +235,20 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return [];
|
||||
}
|
||||
skipFolder = skipFolder.map(e => e.toLowerCase());
|
||||
skipFolder = skipFolder.map((e) => e.toLowerCase());
|
||||
|
||||
let files = [] as string[];
|
||||
for (const file of w.files) {
|
||||
if (excludeFilter && excludeFilter.some(ee => file.match(ee))) {
|
||||
if (excludeFilter && excludeFilter.some((ee) => file.match(ee))) {
|
||||
// If excludeFilter and includeFilter are both set, the file will be included in the list.
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some(e => file.match(e))) continue;
|
||||
if (!includeFilter.some((e) => file.match(e))) continue;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some(e => file.match(e))) continue;
|
||||
if (!includeFilter.some((e) => file.match(e))) continue;
|
||||
}
|
||||
if (await this.plugin.$$isIgnoredByIgnoreFiles(file)) continue;
|
||||
files.push(file);
|
||||
@@ -226,31 +256,31 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
|
||||
for (const v of w.folders) {
|
||||
const folderName = (v.split("/").pop() ?? "").toLowerCase();
|
||||
if (skipFolder.some(e => folderName === e)) {
|
||||
continue
|
||||
if (skipFolder.some((e) => folderName === e)) {
|
||||
continue;
|
||||
}
|
||||
if (excludeFilter && excludeFilter.some(e => v.match(e))) {
|
||||
if (excludeFilter && excludeFilter.some((e) => v.match(e))) {
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some(e => v.match(e))) {
|
||||
if (!includeFilter.some((e) => v.match(e))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some(e => v.match(e))) continue;
|
||||
if (!includeFilter.some((e) => v.match(e))) continue;
|
||||
}
|
||||
// OK, deep dive!
|
||||
files = files.concat(await this.getFilesIncludeHidden(v, includeFilter, excludeFilter, skipFolder));
|
||||
}
|
||||
return files as FilePath[];
|
||||
}
|
||||
touched(file: UXFileInfoStub | FilePathWithPrefix): void {
|
||||
async touched(file: UXFileInfoStub | FilePathWithPrefix): Promise<void> {
|
||||
const path = typeof file === "string" ? file : file.path;
|
||||
this.vaultAccess.touch(path as FilePath);
|
||||
await this.vaultAccess.touch(path as FilePath);
|
||||
}
|
||||
recentlyTouched(file: UXFileInfoStub | FilePathWithPrefix): boolean {
|
||||
const xFile = typeof file === "string" ? this.vaultAccess.getAbstractFileByPath(file) as TFile : file;
|
||||
const xFile = typeof file === "string" ? (this.vaultAccess.getAbstractFileByPath(file) as TFile) : file;
|
||||
if (xFile === null) return false;
|
||||
if (xFile instanceof TFolder) return false;
|
||||
return this.vaultAccess.recentlyTouched(xFile);
|
||||
@@ -295,7 +325,7 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
|
||||
async _deleteVaultItem(file: TFile | TFolder) {
|
||||
if (file instanceof TFile) {
|
||||
if (!await this.core.$$isTargetFile(file.path)) return;
|
||||
if (!(await this.core.$$isTargetFile(file.path))) return;
|
||||
}
|
||||
const dir = file.parent;
|
||||
if (this.settings.trashInsteadDelete) {
|
||||
@@ -308,7 +338,9 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
this._log(`files: ${dir.children.length}`);
|
||||
if (dir.children.length == 0) {
|
||||
if (!this.settings.doNotDeleteFolder) {
|
||||
this._log(`All files under the parent directory (${dir.path}) have been deleted, so delete this one.`);
|
||||
this._log(
|
||||
`All files under the parent directory (${dir.path}) have been deleted, so delete this one.`
|
||||
);
|
||||
await this._deleteVaultItem(dir);
|
||||
}
|
||||
}
|
||||
@@ -323,4 +355,4 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
return await this._deleteVaultItem(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { AbstractObsidianModule, type IObsidianModule } from '../AbstractObsidianModule.ts';
|
||||
import { scheduleTask } from 'octagonal-wheels/concurrency/task';
|
||||
import { disposeMemoObject, memoIfNotExist, memoObject, retrieveMemoObject } from '../../common/utils.ts';
|
||||
import { askSelectString, askString, askYesNo, confirmWithMessage, confirmWithMessageWithWideButton } from './UILib/dialogs.ts';
|
||||
import { Notice } from '../../deps.ts';
|
||||
import type { Confirm } from '../interfaces/Confirm.ts';
|
||||
// ModuleInputUIObsidian.ts
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { disposeMemoObject, memoIfNotExist, memoObject, retrieveMemoObject } from "../../common/utils.ts";
|
||||
import {
|
||||
askSelectString,
|
||||
askString,
|
||||
askYesNo,
|
||||
confirmWithMessage,
|
||||
confirmWithMessageWithWideButton,
|
||||
} from "./UILib/dialogs.ts";
|
||||
import { Notice } from "../../deps.ts";
|
||||
import type { Confirm } from "../../lib/src/interfaces/Confirm.ts";
|
||||
import { setConfirmInstance } from "../../lib/src/PlatformAPIs/obsidian/Confirm.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
|
||||
// This module cannot be a common module because it depends on Obsidian's API.
|
||||
// However, we have to make compatible one for other platform.
|
||||
|
||||
export class ModuleInputUIObsidian extends AbstractObsidianModule implements IObsidianModule, Confirm {
|
||||
|
||||
$everyOnload(): Promise<boolean> {
|
||||
this.core.confirm = this;
|
||||
setConfirmInstance(this);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -22,17 +31,43 @@ export class ModuleInputUIObsidian extends AbstractObsidianModule implements IOb
|
||||
return askString(this.app, title, key, placeholder, isPassword);
|
||||
}
|
||||
|
||||
async askYesNoDialog(message: string, opt: { title?: string, defaultOption?: "Yes" | "No", timeout?: number } = { title: "Confirmation" }): Promise<"yes" | "no"> {
|
||||
const ret = await confirmWithMessageWithWideButton(this.plugin, opt.title || "Confirmation", message, ["Yes", "No"], opt.defaultOption ?? "No", opt.timeout);
|
||||
return ret == "Yes" ? "yes" : "no";
|
||||
async askYesNoDialog(
|
||||
message: string,
|
||||
opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number } = { title: "Confirmation" }
|
||||
): Promise<"yes" | "no"> {
|
||||
const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleConfirmation");
|
||||
const yesLabel = $msg("moduleInputUIObsidian.optionYes");
|
||||
const noLabel = $msg("moduleInputUIObsidian.optionNo");
|
||||
const defaultOption = opt.defaultOption === "Yes" ? yesLabel : noLabel;
|
||||
const ret = await confirmWithMessageWithWideButton(
|
||||
this.plugin,
|
||||
opt.title || defaultTitle,
|
||||
message,
|
||||
[yesLabel, noLabel],
|
||||
defaultOption,
|
||||
opt.timeout
|
||||
);
|
||||
return ret === yesLabel ? "yes" : "no";
|
||||
}
|
||||
|
||||
askSelectString(message: string, items: string[]): Promise<string> {
|
||||
return askSelectString(this.app, message, items);
|
||||
}
|
||||
|
||||
askSelectStringDialogue(message: string, buttons: string[], opt: { title?: string, defaultAction: (typeof buttons)[number], timeout?: number }): Promise<(typeof buttons)[number] | false> {
|
||||
return confirmWithMessageWithWideButton(this.plugin, opt.title || "Select", message, buttons, opt.defaultAction, opt.timeout);
|
||||
askSelectStringDialogue<T extends readonly string[]>(
|
||||
message: string,
|
||||
buttons: T,
|
||||
opt: { title?: string; defaultAction: T[number]; timeout?: number }
|
||||
): Promise<T[number] | false> {
|
||||
const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect");
|
||||
return confirmWithMessageWithWideButton(
|
||||
this.plugin,
|
||||
opt.title || defaultTitle,
|
||||
message,
|
||||
buttons,
|
||||
opt.defaultAction,
|
||||
opt.timeout
|
||||
);
|
||||
}
|
||||
|
||||
askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void) {
|
||||
@@ -40,9 +75,11 @@ export class ModuleInputUIObsidian extends AbstractObsidianModule implements IOb
|
||||
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
|
||||
doc.createEl("span", undefined, (a) => {
|
||||
a.appendText(beforeText);
|
||||
a.appendChild(a.createEl("a", undefined, (anchor) => {
|
||||
anchorCallback(anchor);
|
||||
}));
|
||||
a.appendChild(
|
||||
a.createEl("a", undefined, (anchor) => {
|
||||
anchorCallback(anchor);
|
||||
})
|
||||
);
|
||||
a.appendText(afterText);
|
||||
});
|
||||
});
|
||||
@@ -55,8 +92,7 @@ export class ModuleInputUIObsidian extends AbstractObsidianModule implements IOb
|
||||
}
|
||||
scheduleTask(popupKey + "-close", 20000, () => {
|
||||
const popup = retrieveMemoObject<Notice>(popupKey);
|
||||
if (!popup)
|
||||
return;
|
||||
if (!popup) return;
|
||||
if (popup?.noticeEl?.isShown()) {
|
||||
popup.hide();
|
||||
}
|
||||
@@ -64,8 +100,14 @@ export class ModuleInputUIObsidian extends AbstractObsidianModule implements IOb
|
||||
});
|
||||
});
|
||||
}
|
||||
confirmWithMessage(title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false> {
|
||||
|
||||
confirmWithMessage(
|
||||
title: string,
|
||||
contentMd: string,
|
||||
buttons: string[],
|
||||
defaultAction: (typeof buttons)[number],
|
||||
timeout?: number
|
||||
): Promise<(typeof buttons)[number] | false> {
|
||||
return confirmWithMessage(this.plugin, title, contentMd, buttons, defaultAction, timeout);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { ButtonComponent } from "obsidian";
|
||||
import { App, FuzzySuggestModal, MarkdownRenderer, Modal, Plugin, Setting } from "../../../deps.ts";
|
||||
import { EVENT_PLUGIN_UNLOADED, eventHub } from "../../../common/events.ts";
|
||||
|
||||
export class InputStringDialog extends Modal {
|
||||
class AutoClosableModal extends Modal {
|
||||
_closeByUnload() {
|
||||
eventHub.off(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
this.close();
|
||||
}
|
||||
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
this._closeByUnload = this._closeByUnload.bind(this);
|
||||
eventHub.once(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
}
|
||||
onClose() {
|
||||
eventHub.off(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
}
|
||||
}
|
||||
|
||||
export class InputStringDialog extends AutoClosableModal {
|
||||
result: string | false = false;
|
||||
onSubmit: (result: string | false) => void;
|
||||
title: string;
|
||||
@@ -10,7 +27,14 @@ export class InputStringDialog extends Modal {
|
||||
isManuallyClosed = false;
|
||||
isPassword = false;
|
||||
|
||||
constructor(app: App, title: string, key: string, placeholder: string, isPassword: boolean, onSubmit: (result: string | false) => void) {
|
||||
constructor(
|
||||
app: App,
|
||||
title: string,
|
||||
key: string,
|
||||
placeholder: string,
|
||||
isPassword: boolean,
|
||||
onSubmit: (result: string | false) => void
|
||||
) {
|
||||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
this.title = title;
|
||||
@@ -23,30 +47,36 @@ export class InputStringDialog extends Modal {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
const formEl = contentEl.createDiv();
|
||||
new Setting(formEl).setName(this.key).setClass(this.isPassword ? "password-input" : "normal-input").addText((text) =>
|
||||
text.onChange((value) => {
|
||||
this.result = value;
|
||||
})
|
||||
);
|
||||
new Setting(formEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText("Ok")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.isManuallyClosed = true;
|
||||
this.close();
|
||||
new Setting(formEl)
|
||||
.setName(this.key)
|
||||
.setClass(this.isPassword ? "password-input" : "normal-input")
|
||||
.addText((text) =>
|
||||
text.onChange((value) => {
|
||||
this.result = value;
|
||||
})
|
||||
).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText("Cancel")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
})
|
||||
);
|
||||
);
|
||||
new Setting(formEl)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText("Ok")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.isManuallyClosed = true;
|
||||
this.close();
|
||||
})
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText("Cancel")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
super.onClose();
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.isManuallyClosed) {
|
||||
@@ -58,13 +88,18 @@ export class InputStringDialog extends Modal {
|
||||
}
|
||||
export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
app: App;
|
||||
callback: ((e: string) => void) | undefined = () => { };
|
||||
callback: ((e: string) => void) | undefined = () => {};
|
||||
getItemsFun: () => string[] = () => {
|
||||
return ["yes", "no"];
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
constructor(app: App, note: string, placeholder: string | undefined, getItemsFun: (() => string[]) | undefined, callback: (e: string) => void) {
|
||||
constructor(
|
||||
app: App,
|
||||
note: string,
|
||||
placeholder: string | undefined,
|
||||
getItemsFun: (() => string[]) | undefined,
|
||||
callback: (e: string) => void
|
||||
) {
|
||||
super(app);
|
||||
this.app = app;
|
||||
this.setPlaceholder((placeholder ?? "y/n) ") + note);
|
||||
@@ -95,12 +130,11 @@ export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageBox extends Modal {
|
||||
|
||||
export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
|
||||
plugin: Plugin;
|
||||
title: string;
|
||||
contentMd: string;
|
||||
buttons: string[];
|
||||
buttons: T;
|
||||
result: string | false = false;
|
||||
isManuallyClosed = false;
|
||||
defaultAction: string | undefined;
|
||||
@@ -111,7 +145,16 @@ export class MessageBox extends Modal {
|
||||
|
||||
onSubmit: (result: string | false) => void;
|
||||
|
||||
constructor(plugin: Plugin, title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout: number | undefined, wideButton: boolean, onSubmit: (result: (typeof buttons)[number] | false) => void) {
|
||||
constructor(
|
||||
plugin: Plugin,
|
||||
title: string,
|
||||
contentMd: string,
|
||||
buttons: T,
|
||||
defaultAction: T[number],
|
||||
timeout: number | undefined,
|
||||
wideButton: boolean,
|
||||
onSubmit: (result: T[number] | false) => void
|
||||
) {
|
||||
super(plugin.app);
|
||||
this.plugin = plugin;
|
||||
this.title = title;
|
||||
@@ -144,16 +187,20 @@ export class MessageBox extends Modal {
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.addEventListener("click", () => {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
this.defaultButtonComponent?.setButtonText(`${this.defaultAction}`);
|
||||
}
|
||||
})
|
||||
const div = contentEl.createDiv();
|
||||
div.style.userSelect = "text";
|
||||
div.style["webkitUserSelect"] = "text";
|
||||
void MarkdownRenderer.render(this.plugin.app, this.contentMd, div, "/", this.plugin);
|
||||
const buttonSetting = new Setting(contentEl);
|
||||
const labelWrapper = contentEl.createDiv();
|
||||
labelWrapper.addClass("sls-dialogue-note-wrapper");
|
||||
const labelEl = labelWrapper.createEl("label", { text: "To stop the countdown, tap anywhere on the dialogue" });
|
||||
labelEl.addClass("sls-dialogue-note-countdown");
|
||||
if (!this.timeout || !this.timer) {
|
||||
labelWrapper.empty();
|
||||
labelWrapper.style.display = "none";
|
||||
}
|
||||
|
||||
buttonSetting.infoEl.style.display = "none";
|
||||
buttonSetting.controlEl.style.flexWrap = "wrap";
|
||||
if (this.wideButton) {
|
||||
@@ -162,19 +209,26 @@ export class MessageBox extends Modal {
|
||||
buttonSetting.controlEl.style.justifyContent = "center";
|
||||
buttonSetting.controlEl.style.flexGrow = "1";
|
||||
}
|
||||
contentEl.addEventListener("click", () => {
|
||||
if (this.timer) {
|
||||
labelWrapper.empty();
|
||||
labelWrapper.style.display = "none";
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
this.defaultButtonComponent?.setButtonText(`${this.defaultAction}`);
|
||||
}
|
||||
});
|
||||
for (const button of this.buttons) {
|
||||
buttonSetting.addButton((btn) => {
|
||||
btn
|
||||
.setButtonText(button)
|
||||
.onClick(() => {
|
||||
this.isManuallyClosed = true;
|
||||
this.result = button;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
this.close();
|
||||
})
|
||||
btn.setButtonText(button).onClick(() => {
|
||||
this.isManuallyClosed = true;
|
||||
this.result = button;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
this.close();
|
||||
});
|
||||
if (button == this.defaultAction) {
|
||||
this.defaultButtonComponent = btn;
|
||||
btn.setCta();
|
||||
@@ -184,12 +238,12 @@ export class MessageBox extends Modal {
|
||||
btn.buttonEl.style.width = "100%";
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
super.onClose();
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.timer) {
|
||||
@@ -204,25 +258,42 @@ export class MessageBox extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export function confirmWithMessage(plugin: Plugin, title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false> {
|
||||
export function confirmWithMessage<T extends readonly string[]>(
|
||||
plugin: Plugin,
|
||||
title: string,
|
||||
contentMd: string,
|
||||
buttons: T,
|
||||
defaultAction: T[number],
|
||||
timeout?: number
|
||||
): Promise<T[number] | false> {
|
||||
return new Promise((res) => {
|
||||
const dialog = new MessageBox(plugin, title, contentMd, buttons, defaultAction, timeout, false, (result) => res(result));
|
||||
const dialog = new MessageBox(plugin, title, contentMd, buttons, defaultAction, timeout, false, (result) =>
|
||||
res(result)
|
||||
);
|
||||
dialog.open();
|
||||
});
|
||||
}
|
||||
export function confirmWithMessageWithWideButton(plugin: Plugin, title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false> {
|
||||
export function confirmWithMessageWithWideButton<T extends readonly string[]>(
|
||||
plugin: Plugin,
|
||||
title: string,
|
||||
contentMd: string,
|
||||
buttons: T,
|
||||
defaultAction: T[number],
|
||||
timeout?: number
|
||||
): Promise<T[number] | false> {
|
||||
return new Promise((res) => {
|
||||
const dialog = new MessageBox(plugin, title, contentMd, buttons, defaultAction, timeout, true, (result) => res(result));
|
||||
const dialog = new MessageBox(plugin, title, contentMd, buttons, defaultAction, timeout, true, (result) =>
|
||||
res(result)
|
||||
);
|
||||
dialog.open();
|
||||
});
|
||||
}
|
||||
|
||||
export const askYesNo = (app: App, message: string): Promise<"yes" | "no"> => {
|
||||
return new Promise((res) => {
|
||||
const popover = new PopoverSelectString(app, message, undefined, undefined, (result) => res(result as "yes" | "no"));
|
||||
const popover = new PopoverSelectString(app, message, undefined, undefined, (result) =>
|
||||
res(result as "yes" | "no")
|
||||
);
|
||||
popover.open();
|
||||
});
|
||||
};
|
||||
@@ -235,11 +306,15 @@ export const askSelectString = (app: App, message: string, items: string[]): Pro
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const askString = (app: App, title: string, key: string, placeholder: string, isPassword: boolean = false): Promise<string | false> => {
|
||||
export const askString = (
|
||||
app: App,
|
||||
title: string,
|
||||
key: string,
|
||||
placeholder: string,
|
||||
isPassword: boolean = false
|
||||
): Promise<string | false> => {
|
||||
return new Promise((res) => {
|
||||
const dialog = new InputStringDialog(app, title, key, placeholder, isPassword, (result) => res(result));
|
||||
dialog.open();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { markChangesAreSame } from "../../../common/utils.ts";
|
||||
import { type UXFileInfo } from "../../../lib/src/common/types.ts";
|
||||
|
||||
function getFileLockKey(file: TFile | TFolder | string | UXFileInfo) {
|
||||
return `fl:${typeof (file) == "string" ? file : file.path}`;
|
||||
return `fl:${typeof file == "string" ? file : file.path}`;
|
||||
}
|
||||
function toArrayBuffer(arr: Uint8Array | ArrayBuffer | DataView): ArrayBufferLike {
|
||||
if (arr instanceof Uint8Array) {
|
||||
@@ -35,13 +35,20 @@ async function processWriteFile<T>(file: TFile | TFolder | string | UXFileInfo,
|
||||
}
|
||||
|
||||
export class SerializedFileAccess {
|
||||
app: App
|
||||
plugin: HasSettings<{ handleFilenameCaseSensitive: boolean }>
|
||||
constructor(app: App, plugin: typeof this["plugin"]) {
|
||||
app: App;
|
||||
plugin: HasSettings<{ handleFilenameCaseSensitive: boolean }>;
|
||||
constructor(app: App, plugin: (typeof this)["plugin"]) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
async tryAdapterStat(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
return await processReadFile(file, async () => {
|
||||
if (!(await this.app.vault.adapter.exists(path))) return null;
|
||||
return this.app.vault.adapter.stat(path);
|
||||
});
|
||||
}
|
||||
async adapterStat(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
return await processReadFile(file, () => this.app.vault.adapter.stat(path));
|
||||
@@ -72,10 +79,12 @@ export class SerializedFileAccess {
|
||||
|
||||
async adapterWrite(file: TFile | string, data: string | ArrayBuffer | Uint8Array, options?: DataWriteOptions) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
if (typeof (data) === "string") {
|
||||
if (typeof data === "string") {
|
||||
return await processWriteFile(file, () => this.app.vault.adapter.write(path, data, options));
|
||||
} else {
|
||||
return await processWriteFile(file, () => this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options));
|
||||
return await processWriteFile(file, () =>
|
||||
this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,19 +106,17 @@ export class SerializedFileAccess {
|
||||
return await processReadFile(file, () => this.app.vault.readBinary(file));
|
||||
}
|
||||
|
||||
|
||||
async vaultModify(file: TFile, data: string | ArrayBuffer | Uint8Array, options?: DataWriteOptions) {
|
||||
if (typeof (data) === "string") {
|
||||
if (typeof data === "string") {
|
||||
return await processWriteFile(file, async () => {
|
||||
const oldData = await this.app.vault.read(file);
|
||||
if (data === oldData) {
|
||||
if (options && options.mtime) markChangesAreSame(file.path, file.stat.mtime, options.mtime);
|
||||
return true;
|
||||
}
|
||||
await this.app.vault.modify(file, data, options)
|
||||
await this.app.vault.modify(file, data, options);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
return await processWriteFile(file, async () => {
|
||||
const oldData = await this.app.vault.readBinary(file);
|
||||
@@ -117,13 +124,17 @@ export class SerializedFileAccess {
|
||||
if (options && options.mtime) markChangesAreSame(file.path, file.stat.mtime, options.mtime);
|
||||
return true;
|
||||
}
|
||||
await this.app.vault.modifyBinary(file, toArrayBuffer(data), options)
|
||||
await this.app.vault.modifyBinary(file, toArrayBuffer(data), options);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
async vaultCreate(path: string, data: string | ArrayBuffer | Uint8Array, options?: DataWriteOptions): Promise<TFile> {
|
||||
if (typeof (data) === "string") {
|
||||
async vaultCreate(
|
||||
path: string,
|
||||
data: string | ArrayBuffer | Uint8Array,
|
||||
options?: DataWriteOptions
|
||||
): Promise<TFile> {
|
||||
if (typeof data === "string") {
|
||||
return await processWriteFile(path, () => this.app.vault.create(path, data, options));
|
||||
} else {
|
||||
return await processWriteFile(path, () => this.app.vault.createBinary(path, toArrayBuffer(data), options));
|
||||
@@ -135,7 +146,7 @@ export class SerializedFileAccess {
|
||||
}
|
||||
|
||||
async adapterAppend(normalizedPath: string, data: string, options?: DataWriteOptions) {
|
||||
return await this.app.vault.adapter.append(normalizedPath, data, options)
|
||||
return await this.app.vault.adapter.append(normalizedPath, data, options);
|
||||
}
|
||||
|
||||
async delete(file: TFile | TFolder, force = false) {
|
||||
@@ -145,8 +156,6 @@ export class SerializedFileAccess {
|
||||
return await processWriteFile(file, () => this.app.vault.trash(file, force));
|
||||
}
|
||||
|
||||
|
||||
|
||||
isStorageInsensitive(): boolean {
|
||||
//@ts-ignore
|
||||
return this.app.vault.adapter.insensitive ?? true;
|
||||
@@ -188,22 +197,29 @@ export class SerializedFileAccess {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
touchedFiles: string[] = [];
|
||||
|
||||
_statInternal(file: FilePath) {
|
||||
return this.app.vault.adapter.stat(file);
|
||||
}
|
||||
|
||||
touch(file: TFile | FilePath) {
|
||||
const f = file instanceof TFile ? file : this.getAbstractFileByPath(file) as TFile;
|
||||
const key = `${f.path}-${f.stat.mtime}-${f.stat.size}`;
|
||||
async touch(file: TFile | FilePath) {
|
||||
const path = file instanceof TFile ? (file.path as FilePath) : file;
|
||||
const statOrg = file instanceof TFile ? file.stat : await this._statInternal(path);
|
||||
const stat = statOrg || { mtime: 0, size: 0 };
|
||||
const key = `${path}-${stat.mtime}-${stat.size}`;
|
||||
this.touchedFiles.unshift(key);
|
||||
this.touchedFiles = this.touchedFiles.slice(0, 100);
|
||||
}
|
||||
recentlyTouched(file: TFile | InternalFileInfo | UXFileInfoStub) {
|
||||
const key = "stat" in file ? `${file.path}-${file.stat.mtime}-${file.stat.size}` : `${file.path}-${file.mtime}-${file.size}`;
|
||||
const key =
|
||||
"stat" in file
|
||||
? `${file.path}-${file.stat.mtime}-${file.stat.size}`
|
||||
: `${file.path}-${file.mtime}-${file.size}`;
|
||||
if (this.touchedFiles.indexOf(key) == -1) return false;
|
||||
return true;
|
||||
}
|
||||
clearTouched() {
|
||||
this.touchedFiles = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { TAbstractFile, TFile, TFolder } from "../../../deps.ts";
|
||||
import { Logger } from "../../../lib/src/common/logger.ts";
|
||||
import { shouldBeIgnored } from "../../../lib/src/string_and_binary/path.ts";
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, type FilePath, type FilePathWithPrefix, type UXFileInfoStub, type UXInternalFileInfoStub } from "../../../lib/src/common/types.ts";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_DEBUG,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type UXFileInfoStub,
|
||||
type UXInternalFileInfoStub,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import { delay, fireAndForget } from "../../../lib/src/common/utils.ts";
|
||||
import { type FileEventItem, type FileEventType } from "../../../common/types.ts";
|
||||
import { serialized, skipIfDuplicated } from "../../../lib/src/concurrency/lock.ts";
|
||||
import { finishAllWaitingForTimeout, finishWaitingForTimeout, isWaitingForTimeout, waitForTimeout } from "../../../lib/src/concurrency/task.ts";
|
||||
import {
|
||||
finishAllWaitingForTimeout,
|
||||
finishWaitingForTimeout,
|
||||
isWaitingForTimeout,
|
||||
waitForTimeout,
|
||||
} from "../../../lib/src/concurrency/task.ts";
|
||||
import { Semaphore } from "../../../lib/src/concurrency/semaphore.ts";
|
||||
import type { LiveSyncCore } from "../../../main.ts";
|
||||
import { InternalFileToUXFileInfoStub, TFileToUXFileInfoStub } from "./utilObsidian.ts";
|
||||
import ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
// import { InternalFileToUXFileInfo } from "../platforms/obsidian.ts";
|
||||
|
||||
|
||||
export type FileEvent = {
|
||||
type: FileEventType;
|
||||
file: UXFileInfoStub | UXInternalFileInfoStub;
|
||||
@@ -21,30 +35,26 @@ export type FileEvent = {
|
||||
skipBatchWait?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export abstract class StorageEventManager {
|
||||
abstract beginWatch(): void;
|
||||
abstract flushQueue(): void;
|
||||
abstract appendQueue(items: FileEvent[], ctx?: any): Promise<void>;
|
||||
abstract cancelQueue(key: string): void;
|
||||
abstract isWaiting(filename: FilePath): boolean;
|
||||
|
||||
}
|
||||
|
||||
|
||||
export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
core: LiveSyncCore;
|
||||
|
||||
get shouldBatchSave() {
|
||||
return this.plugin.shouldBatchSave;
|
||||
return this.core.settings?.batchSave && this.core.settings?.liveSync != true;
|
||||
}
|
||||
get batchSaveMinimumDelay(): number {
|
||||
return this.plugin.batchSaveMinimumDelay;
|
||||
return this.core.settings?.batchSaveMinimumDelay ?? DEFAULT_SETTINGS.batchSaveMinimumDelay;
|
||||
}
|
||||
get batchSaveMaximumDelay(): number {
|
||||
return this.plugin.batchSaveMaximumDelay
|
||||
return this.core.settings?.batchSaveMaximumDelay ?? DEFAULT_SETTINGS.batchSaveMaximumDelay;
|
||||
}
|
||||
constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore) {
|
||||
super();
|
||||
@@ -83,10 +93,11 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
}
|
||||
const data = info?.data as string;
|
||||
const fi: FileEvent = {
|
||||
type: "CHANGED", file: TFileToUXFileInfoStub(file), cachedData: data,
|
||||
}
|
||||
void this.appendQueue([
|
||||
fi])
|
||||
type: "CHANGED",
|
||||
file: TFileToUXFileInfoStub(file),
|
||||
cachedData: data,
|
||||
};
|
||||
void this.appendQueue([fi]);
|
||||
}
|
||||
|
||||
watchVaultCreate(file: TAbstractFile, ctx?: any) {
|
||||
@@ -109,17 +120,27 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
watchVaultRename(file: TAbstractFile, oldFile: string, ctx?: any) {
|
||||
if (file instanceof TFile) {
|
||||
const fileInfo = TFileToUXFileInfoStub(file);
|
||||
void this.appendQueue([
|
||||
{
|
||||
type: "DELETE", file: {
|
||||
path: oldFile as FilePath, name: file.name, stat: {
|
||||
mtime: file.stat.mtime,
|
||||
ctime: file.stat.ctime,
|
||||
size: file.stat.size,
|
||||
type: "file"
|
||||
}, deleted: true
|
||||
}, skipBatchWait: true
|
||||
}, { type: "CREATE", file: fileInfo, skipBatchWait: true },], ctx);
|
||||
void this.appendQueue(
|
||||
[
|
||||
{
|
||||
type: "DELETE",
|
||||
file: {
|
||||
path: oldFile as FilePath,
|
||||
name: file.name,
|
||||
stat: {
|
||||
mtime: file.stat.mtime,
|
||||
ctime: file.stat.ctime,
|
||||
size: file.stat.size,
|
||||
type: "file",
|
||||
},
|
||||
deleted: true,
|
||||
},
|
||||
skipBatchWait: true,
|
||||
},
|
||||
{ type: "CREATE", file: fileInfo, skipBatchWait: true },
|
||||
],
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
// Watch raw events (Internal API)
|
||||
@@ -142,68 +163,83 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
if (!path.startsWith(this.plugin.app.vault.configDir)) return;
|
||||
const ignorePatterns = this.plugin.settings.syncInternalFilesIgnorePatterns
|
||||
.replace(/\n| /g, "")
|
||||
.split(",").filter(e => e).map(e => new RegExp(e, "i"));
|
||||
if (ignorePatterns.some(e => path.match(e))) return;
|
||||
.split(",")
|
||||
.filter((e) => e)
|
||||
.map((e) => new RegExp(e, "i"));
|
||||
if (ignorePatterns.some((e) => path.match(e))) return;
|
||||
if (path.endsWith("/")) {
|
||||
// Folder
|
||||
// Folder
|
||||
return;
|
||||
}
|
||||
void this.appendQueue([
|
||||
{
|
||||
type: "INTERNAL", file: InternalFileToUXFileInfoStub(path),
|
||||
}], null);
|
||||
|
||||
void this.appendQueue(
|
||||
[
|
||||
{
|
||||
type: "INTERNAL",
|
||||
file: InternalFileToUXFileInfoStub(path),
|
||||
skipBatchWait: true, // Internal files should be processed immediately.
|
||||
},
|
||||
],
|
||||
null
|
||||
);
|
||||
}
|
||||
// Cache file and waiting to can be proceed.
|
||||
async appendQueue(params: FileEvent[], ctx?: any) {
|
||||
if (!this.plugin.settings.isConfigured) return;
|
||||
if (this.plugin.settings.suspendFileWatching) return;
|
||||
this.plugin.totalFileEventCount++;
|
||||
if (!this.core.settings.isConfigured) return;
|
||||
if (this.core.settings.suspendFileWatching) return;
|
||||
this.core.$$markFileListPossiblyChanged();
|
||||
// Flag up to be reload
|
||||
const processFiles = new Set<FilePath>();
|
||||
for (const param of params) {
|
||||
if (shouldBeIgnored(param.file.path)) {
|
||||
continue;
|
||||
}
|
||||
const atomicKey = [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0].map(e => `${Math.floor(Math.random() * 100000)}`).join("-");
|
||||
const atomicKey = [0, 0, 0, 0, 0, 0].map((e) => `${Math.floor(Math.random() * 100000)}`).join("-");
|
||||
const type = param.type;
|
||||
const file = param.file;
|
||||
const oldPath = param.oldPath;
|
||||
if (type !== "INTERNAL") {
|
||||
const size = (file as UXFileInfoStub).stat.size;
|
||||
if (this.plugin.$$isFileSizeExceeded(size) && (type == "CREATE" || type == "CHANGED")) {
|
||||
Logger(`The storage file has been changed but exceeds the maximum size. Skipping: ${param.file.path}`, LOG_LEVEL_NOTICE);
|
||||
if (this.core.$$isFileSizeExceeded(size) && (type == "CREATE" || type == "CHANGED")) {
|
||||
Logger(
|
||||
`The storage file has been changed but exceeds the maximum size. Skipping: ${param.file.path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (file instanceof TFolder) continue;
|
||||
if (!await this.plugin.$$isTargetFile(file.path)) continue;
|
||||
if (!(await this.core.$$isTargetFile(file.path))) continue;
|
||||
|
||||
// Stop cache using to prevent the corruption;
|
||||
// let cache: null | string | ArrayBuffer;
|
||||
// new file or something changed, cache the changes.
|
||||
if (file instanceof TFile && (type == "CREATE" || type == "CHANGED")) {
|
||||
// Wait for a bit while to let the writer has marked `touched` at the file.
|
||||
await delay(10);
|
||||
if (this.plugin.storageAccess.recentlyTouched(file)) {
|
||||
continue;
|
||||
// if (file instanceof TFile && (type == "CREATE" || type == "CHANGED")) {
|
||||
if (file instanceof TFile || !file.isFolder) {
|
||||
if (type == "CREATE" || type == "CHANGED") {
|
||||
// Wait for a bit while to let the writer has marked `touched` at the file.
|
||||
await delay(10);
|
||||
if (this.core.storageAccess.recentlyTouched(file.path)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cache: string | undefined = undefined;
|
||||
if (param.cachedData) {
|
||||
cache = param.cachedData
|
||||
cache = param.cachedData;
|
||||
}
|
||||
this.enqueue({
|
||||
type, args: {
|
||||
file: file, oldPath, cache, ctx,
|
||||
}, skipBatchWait: param.skipBatchWait, key: atomicKey
|
||||
})
|
||||
type,
|
||||
args: {
|
||||
file: file,
|
||||
oldPath,
|
||||
cache,
|
||||
ctx,
|
||||
},
|
||||
skipBatchWait: param.skipBatchWait,
|
||||
key: atomicKey,
|
||||
});
|
||||
processFiles.add(file.path as FilePath);
|
||||
if (oldPath) {
|
||||
processFiles.add(oldPath as FilePath);
|
||||
@@ -238,7 +274,7 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
Logger(`Processing ${filename}: Started`, LOG_LEVEL_DEBUG);
|
||||
let noMoreFiles = false;
|
||||
do {
|
||||
const target = this.bufferedQueuedItems.find(e => e.args.file.path == filename);
|
||||
const target = this.bufferedQueuedItems.find((e) => e.args.file.path == filename);
|
||||
if (target === undefined) {
|
||||
noMoreFiles = true;
|
||||
break;
|
||||
@@ -251,7 +287,7 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
// }
|
||||
const type = target.type;
|
||||
if (target.cancelled) {
|
||||
Logger(`Processing ${filename}: Cancelled (scheduled): ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
Logger(`Processing ${filename}: Cancelled (scheduled): ${operationType}`, LOG_LEVEL_DEBUG);
|
||||
this.cancelStandingBy(target);
|
||||
continue;
|
||||
}
|
||||
@@ -261,19 +297,31 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
let canWait = true;
|
||||
const now = Date.now();
|
||||
if (waitedSince !== undefined) {
|
||||
if (waitedSince + (this.batchSaveMaximumDelay * 1000) < now) {
|
||||
Logger(`Processing ${filename}: Could not wait no more: ${operationType}`, LOG_LEVEL_INFO)
|
||||
if (waitedSince + this.batchSaveMaximumDelay * 1000 < now) {
|
||||
Logger(
|
||||
`Processing ${filename}: Could not wait no more: ${operationType}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
canWait = false;
|
||||
}
|
||||
}
|
||||
if (canWait) {
|
||||
if (waitedSince === undefined) this.waitedSince.set(filename, now)
|
||||
target.batched = true
|
||||
Logger(`Processing ${filename}: Waiting for batch save delay: ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
if (waitedSince === undefined) this.waitedSince.set(filename, now);
|
||||
target.batched = true;
|
||||
Logger(
|
||||
`Processing ${filename}: Waiting for batch save delay: ${operationType}`,
|
||||
LOG_LEVEL_DEBUG
|
||||
);
|
||||
this.updateStatus();
|
||||
const result = await waitForTimeout(`storage-event-manager-batchsave-${filename}`, this.batchSaveMinimumDelay * 1000);
|
||||
const result = await waitForTimeout(
|
||||
`storage-event-manager-batchsave-${filename}`,
|
||||
this.batchSaveMinimumDelay * 1000
|
||||
);
|
||||
if (!result) {
|
||||
Logger(`Processing ${filename}: Cancelled by new queue: ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
Logger(
|
||||
`Processing ${filename}: Cancelled by new queue: ${operationType}`,
|
||||
LOG_LEVEL_DEBUG
|
||||
);
|
||||
// If could not wait for the timeout, possibly we got a new queue. therefore, currently processing one should be cancelled
|
||||
this.cancelStandingBy(target);
|
||||
continue;
|
||||
@@ -281,16 +329,19 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger(`Processing ${filename}:Requested to perform immediately ${filename}: ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
Logger(
|
||||
`Processing ${filename}:Requested to perform immediately ${filename}: ${operationType}`,
|
||||
LOG_LEVEL_DEBUG
|
||||
);
|
||||
}
|
||||
Logger(`Processing ${filename}: Request main to process: ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
Logger(`Processing ${filename}: Request main to process: ${operationType}`, LOG_LEVEL_DEBUG);
|
||||
await this.requestProcessQueue(target);
|
||||
} while (!noMoreFiles)
|
||||
} while (!noMoreFiles);
|
||||
} finally {
|
||||
release()
|
||||
release();
|
||||
}
|
||||
Logger(`Processing ${filename}: Finished`, LOG_LEVEL_DEBUG);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
cancelStandingBy(fei: FileEventItem) {
|
||||
@@ -302,30 +353,30 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
try {
|
||||
this.processingCount++;
|
||||
this.bufferedQueuedItems.remove(fei);
|
||||
this.updateStatus()
|
||||
this.updateStatus();
|
||||
this.waitedSince.delete(fei.args.file.path);
|
||||
await this.handleFileEvent(fei);
|
||||
} finally {
|
||||
this.processingCount--;
|
||||
this.updateStatus()
|
||||
this.updateStatus();
|
||||
}
|
||||
}
|
||||
isWaiting(filename: FilePath) {
|
||||
return isWaitingForTimeout(`storage-event-manager-batchsave-${filename}`);
|
||||
}
|
||||
flushQueue() {
|
||||
this.bufferedQueuedItems.forEach(e => e.skipBatchWait = true)
|
||||
this.bufferedQueuedItems.forEach((e) => (e.skipBatchWait = true));
|
||||
finishAllWaitingForTimeout("storage-event-manager-batchsave-", true);
|
||||
}
|
||||
cancelQueue(key: string) {
|
||||
this.bufferedQueuedItems.forEach(e => {
|
||||
if (e.key === key) e.skipBatchWait = true
|
||||
})
|
||||
this.bufferedQueuedItems.forEach((e) => {
|
||||
if (e.key === key) e.skipBatchWait = true;
|
||||
});
|
||||
}
|
||||
updateStatus() {
|
||||
const allItems = this.bufferedQueuedItems.filter(e => !e.cancelled)
|
||||
const batchedCount = allItems.filter(e => e.batched && !e.skipBatchWait).length;
|
||||
this.core.batched.value = batchedCount
|
||||
const allItems = this.bufferedQueuedItems.filter((e) => !e.cancelled);
|
||||
const batchedCount = allItems.filter((e) => e.batched && !e.skipBatchWait).length;
|
||||
this.core.batched.value = batchedCount;
|
||||
this.core.processing.value = this.processingCount;
|
||||
this.core.totalQueued.value = allItems.length - batchedCount;
|
||||
}
|
||||
@@ -334,25 +385,26 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
const file = queue.args.file;
|
||||
const lockKey = `handleFile:${file.path}`;
|
||||
return await serialized(lockKey, async () => {
|
||||
// TODO CHECK
|
||||
const key = `file-last-proc-${queue.type}-${file.path}`;
|
||||
const last = Number(await this.core.kvDB.get(key) || 0);
|
||||
if (queue.type == "INTERNAL" || file.isInternal) {
|
||||
await this.plugin.$anyProcessOptionalFileEvent(file.path as unknown as FilePath);
|
||||
await this.core.$anyProcessOptionalFileEvent(file.path as unknown as FilePath);
|
||||
} else {
|
||||
// let mtime = file.stat.mtime;
|
||||
const key = `file-last-proc-${queue.type}-${file.path}`;
|
||||
const last = Number((await this.core.kvDB.get(key)) || 0);
|
||||
if (queue.type == "DELETE") {
|
||||
await this.plugin.$anyHandlerProcessesFileEvent(queue);
|
||||
await this.core.$anyHandlerProcessesFileEvent(queue);
|
||||
} else {
|
||||
if (file.stat.mtime == last) {
|
||||
Logger(`File has been already scanned on ${queue.type}, skip: ${file.path}`, LOG_LEVEL_VERBOSE);
|
||||
// Should Cancel the relative operations? (e.g. rename)
|
||||
// Should Cancel the relative operations? (e.g. rename)
|
||||
// this.cancelRelativeEvent(queue);
|
||||
return;
|
||||
}
|
||||
if (!await this.plugin.$anyHandlerProcessesFileEvent(queue)) {
|
||||
Logger(`STORAGE -> DB: Handler failed, cancel the relative operations: ${file.path}`, LOG_LEVEL_INFO);
|
||||
// cancel running queues and remove one of atomic operation (e.g. rename)
|
||||
if (!(await this.core.$anyHandlerProcessesFileEvent(queue))) {
|
||||
Logger(
|
||||
`STORAGE -> DB: Handler failed, cancel the relative operations: ${file.path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
// cancel running queues and remove one of atomic operation (e.g. rename)
|
||||
this.cancelRelativeEvent(queue);
|
||||
return;
|
||||
}
|
||||
@@ -364,4 +416,4 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
cancelRelativeEvent(item: FileEventItem): void {
|
||||
this.cancelQueue(item.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,22 @@ import type { SerializedFileAccess } from "./SerializedFileAccess.ts";
|
||||
import { addPrefix, isPlainText } from "../../../lib/src/string_and_binary/path.ts";
|
||||
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { createBlob } from "../../../lib/src/common/utils.ts";
|
||||
import type { FilePath, FilePathWithPrefix, UXFileInfo, UXFileInfoStub, UXFolderInfo, UXInternalFileInfoStub } from "../../../lib/src/common/types.ts";
|
||||
import type {
|
||||
FilePath,
|
||||
FilePathWithPrefix,
|
||||
UXFileInfo,
|
||||
UXFileInfoStub,
|
||||
UXFolderInfo,
|
||||
UXInternalFileInfoStub,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import type { LiveSyncCore } from "../../../main.ts";
|
||||
|
||||
export async function TFileToUXFileInfo(core: LiveSyncCore, file: TFile, prefix?: string, deleted?: boolean): Promise<UXFileInfo> {
|
||||
export async function TFileToUXFileInfo(
|
||||
core: LiveSyncCore,
|
||||
file: TFile,
|
||||
prefix?: string,
|
||||
deleted?: boolean
|
||||
): Promise<UXFileInfo> {
|
||||
const isPlain = isPlainText(file.name);
|
||||
const possiblyLarge = !isPlain;
|
||||
let content: Blob;
|
||||
@@ -34,13 +46,16 @@ export async function TFileToUXFileInfo(core: LiveSyncCore, file: TFile, prefix?
|
||||
type: "file",
|
||||
},
|
||||
body: content,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function InternalFileToUXFileInfo(fullPath: string, vaultAccess: SerializedFileAccess, prefix: string = ICHeader): Promise<UXFileInfo> {
|
||||
|
||||
export async function InternalFileToUXFileInfo(
|
||||
fullPath: string,
|
||||
vaultAccess: SerializedFileAccess,
|
||||
prefix: string = ICHeader
|
||||
): Promise<UXFileInfo> {
|
||||
const name = fullPath.split("/").pop() as string;
|
||||
const stat = await vaultAccess.adapterStat(fullPath);
|
||||
const stat = await vaultAccess.tryAdapterStat(fullPath);
|
||||
if (stat == null) throw new Error(`File not found: ${fullPath}`);
|
||||
if (stat.type == "folder") throw new Error(`File not found: ${fullPath}`);
|
||||
const file = await vaultAccess.adapterReadAuto(fullPath);
|
||||
@@ -64,7 +79,7 @@ export async function InternalFileToUXFileInfo(fullPath: string, vaultAccess: Se
|
||||
type: "file",
|
||||
},
|
||||
body: content,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function TFileToUXFileInfoStub(file: TFile | TAbstractFile, deleted?: boolean): UXFileInfoStub {
|
||||
@@ -81,8 +96,8 @@ export function TFileToUXFileInfoStub(file: TFile | TAbstractFile, deleted?: boo
|
||||
ctime: file.stat.ctime,
|
||||
type: "file",
|
||||
},
|
||||
deleted: deleted
|
||||
}
|
||||
deleted: deleted,
|
||||
};
|
||||
return ret;
|
||||
}
|
||||
export function InternalFileToUXFileInfoStub(filename: FilePathWithPrefix, deleted?: boolean): UXInternalFileInfoStub {
|
||||
@@ -93,8 +108,8 @@ export function InternalFileToUXFileInfoStub(filename: FilePathWithPrefix, delet
|
||||
isFolder: false,
|
||||
stat: undefined,
|
||||
isInternal: true,
|
||||
deleted
|
||||
}
|
||||
deleted,
|
||||
};
|
||||
return ret;
|
||||
}
|
||||
export function TFolderToUXFileInfoStub(file: TFolder): UXFolderInfo {
|
||||
@@ -103,7 +118,7 @@ export function TFolderToUXFileInfoStub(file: TFolder): UXFolderInfo {
|
||||
path: file.path as FilePathWithPrefix,
|
||||
parent: file.parent?.path as FilePath | undefined,
|
||||
isFolder: true,
|
||||
children: file.children.map(e => TFileToUXFileInfoStub(e)),
|
||||
}
|
||||
children: file.children.map((e) => TFileToUXFileInfoStub(e)),
|
||||
};
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { throttle } from "octagonal-wheels/function";
|
||||
import { eventHub } from "../../common/events.ts";
|
||||
import { BASE_IS_NEW, compareFileFreshness, EVEN, getPath, isValidPath, TARGET_IS_NEW } from "../../common/utils.ts";
|
||||
import { type FilePathWithPrefixLC, type FilePathWithPrefix, type MetaEntry, isMetaEntry, type EntryDoc, LOG_LEVEL_VERBOSE, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO, LOG_LEVEL_DEBUG, type UXFileInfoStub } from "../../lib/src/common/types.ts";
|
||||
import {
|
||||
type FilePathWithPrefixLC,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
isMetaEntry,
|
||||
type EntryDoc,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_DEBUG,
|
||||
type UXFileInfoStub,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { isAnyNote } from "../../lib/src/common/utils.ts";
|
||||
import { stripAllPrefixes } from "../../lib/src/string_and_binary/path.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
import { withConcurrency } from "octagonal-wheels/iterable/map";
|
||||
export class ModuleInitializerFile extends AbstractModule implements ICoreModule {
|
||||
|
||||
async $$performFullScan(showingNotice?: boolean): Promise<void> {
|
||||
|
||||
this._log("Opening the key-value database", LOG_LEVEL_VERBOSE);
|
||||
const isInitialized = await (this.core.kvDB.get<boolean>("initialized")) || false;
|
||||
const isInitialized = (await this.core.kvDB.get<boolean>("initialized")) || false;
|
||||
// synchronize all files between database and storage.
|
||||
if (!this.settings.isConfigured) {
|
||||
if (showingNotice) {
|
||||
this._log("LiveSync is not configured yet. Synchronising between the storage and the local database is now prevented.", LOG_LEVEL_NOTICE, "syncAll");
|
||||
this._log(
|
||||
"LiveSync is not configured yet. Synchronising between the storage and the local database is now prevented.",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"syncAll"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -47,33 +59,41 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
return path as FilePathWithPrefixLC;
|
||||
}
|
||||
return (path as string).toLowerCase() as FilePathWithPrefixLC;
|
||||
}
|
||||
};
|
||||
|
||||
// If handleFilenameCaseSensitive is enabled, `FilePathWithPrefixLC` is the same as `FilePathWithPrefix`.
|
||||
|
||||
const storageFileNameMap = Object.fromEntries(_filesStorage.map((e) => [
|
||||
e.path, e] as [FilePathWithPrefix, UXFileInfoStub]));
|
||||
const storageFileNameMap = Object.fromEntries(
|
||||
_filesStorage.map((e) => [e.path, e] as [FilePathWithPrefix, UXFileInfoStub])
|
||||
);
|
||||
|
||||
const storageFileNames = Object.keys(storageFileNameMap) as FilePathWithPrefix[];
|
||||
|
||||
const storageFileNameCapsPair = storageFileNames.map((e) => [
|
||||
e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]);
|
||||
const storageFileNameCapsPair = storageFileNames.map(
|
||||
(e) => [e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]
|
||||
);
|
||||
|
||||
// const storageFileNameCS2CI = Object.fromEntries(storageFileNameCapsPair) as Record<FilePathWithPrefix, FilePathWithPrefixLC>;
|
||||
const storageFileNameCI2CS = Object.fromEntries(storageFileNameCapsPair.map(e => [
|
||||
e[1], e[0]])) as Record<FilePathWithPrefixLC, FilePathWithPrefix>;
|
||||
|
||||
const storageFileNameCI2CS = Object.fromEntries(storageFileNameCapsPair.map((e) => [e[1], e[0]])) as Record<
|
||||
FilePathWithPrefixLC,
|
||||
FilePathWithPrefix
|
||||
>;
|
||||
|
||||
this._log("Collecting local files on the DB", LOG_LEVEL_VERBOSE);
|
||||
const _DBEntries = [] as MetaEntry[];
|
||||
// const _DBEntriesTask = [] as (() => Promise<MetaEntry | false>)[];
|
||||
let count = 0;
|
||||
for await (const doc of this.localDatabase.findAllNormalDocs()) {
|
||||
// Fetch all documents from the database (including conflicts to prevent overwriting).
|
||||
for await (const doc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
count++;
|
||||
if (count % 25 == 0) this._log(`Collecting local files on the DB: ${count}`, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll");
|
||||
if (count % 25 == 0)
|
||||
this._log(
|
||||
`Collecting local files on the DB: ${count}`,
|
||||
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
||||
"syncAll"
|
||||
);
|
||||
const path = getPath(doc);
|
||||
|
||||
if (isValidPath(path) && await this.core.$$isTargetFile(path, true)) {
|
||||
if (isValidPath(path) && (await this.core.$$isTargetFile(path, true))) {
|
||||
if (!isMetaEntry(doc)) {
|
||||
this._log(`Invalid entry: ${path}`, LOG_LEVEL_INFO);
|
||||
continue;
|
||||
@@ -82,24 +102,28 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
}
|
||||
}
|
||||
|
||||
const databaseFileNameMap = Object.fromEntries(_DBEntries.map((e) => [
|
||||
getPath(e), e] as [FilePathWithPrefix, MetaEntry]));
|
||||
const databaseFileNameMap = Object.fromEntries(
|
||||
_DBEntries.map((e) => [getPath(e), e] as [FilePathWithPrefix, MetaEntry])
|
||||
);
|
||||
const databaseFileNames = Object.keys(databaseFileNameMap) as FilePathWithPrefix[];
|
||||
const databaseFileNameCapsPair = databaseFileNames.map((e) => [
|
||||
e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]);
|
||||
const databaseFileNameCapsPair = databaseFileNames.map(
|
||||
(e) => [e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]
|
||||
);
|
||||
// const databaseFileNameCS2CI = Object.fromEntries(databaseFileNameCapsPair) as Record<FilePathWithPrefix, FilePathWithPrefixLC>;
|
||||
const databaseFileNameCI2CS = Object.fromEntries(databaseFileNameCapsPair.map(e => [
|
||||
e[1], e[0]])) as Record<FilePathWithPrefix, FilePathWithPrefixLC>;
|
||||
const databaseFileNameCI2CS = Object.fromEntries(databaseFileNameCapsPair.map((e) => [e[1], e[0]])) as Record<
|
||||
FilePathWithPrefix,
|
||||
FilePathWithPrefixLC
|
||||
>;
|
||||
|
||||
const allFiles = unique([
|
||||
...Object.keys(databaseFileNameCI2CS),
|
||||
...Object.keys(storageFileNameCI2CS)]) as FilePathWithPrefixLC[];
|
||||
...Object.keys(storageFileNameCI2CS),
|
||||
]) as FilePathWithPrefixLC[];
|
||||
|
||||
this._log(`Total files in the database: ${databaseFileNames.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Total files in the storage: ${storageFileNames.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Total files: ${allFiles.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
|
||||
|
||||
const filesExistOnlyInStorage = allFiles.filter((e) => !databaseFileNameCI2CS[e]);
|
||||
const filesExistOnlyInDatabase = allFiles.filter((e) => !storageFileNameCI2CS[e]);
|
||||
const filesExistBoth = allFiles.filter((e) => databaseFileNameCI2CS[e] && storageFileNameCI2CS[e]);
|
||||
@@ -127,93 +151,112 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
if (!this.localDatabase.isReady) throw Error("Database is not ready!");
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const step = 10;
|
||||
const processor = new QueueProcessor(async (e) => {
|
||||
try {
|
||||
await callback(e[0]);
|
||||
let total = 0;
|
||||
for await (const result of withConcurrency(
|
||||
objects,
|
||||
async (e) => {
|
||||
try {
|
||||
await callback(e);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log(`Error while ${procedureName}`, LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
10
|
||||
)) {
|
||||
if (result) {
|
||||
success++;
|
||||
// return
|
||||
} catch (ex) {
|
||||
this._log(`Error while ${procedureName}`, LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
if ((success + failed) % step == 0) {
|
||||
const msg = `${procedureName}: DONE:${success}, FAILED:${failed}, LAST:${processor._queue.length}`;
|
||||
updateLog(procedureName, msg);
|
||||
}
|
||||
return;
|
||||
}, {
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
suspended: true,
|
||||
maintainDelay: false,
|
||||
interval: 0
|
||||
}, objects)
|
||||
await processor.waitForAllDoneAndTerminate();
|
||||
total++;
|
||||
const msg = `${procedureName}: DONE:${success}, FAILED:${failed}, LAST:${objects.length - total}`;
|
||||
updateLog(procedureName, msg);
|
||||
}
|
||||
const msg = `${procedureName} All done: DONE:${success}, FAILED:${failed}`;
|
||||
updateLog(procedureName, msg)
|
||||
}
|
||||
initProcess.push(runAll("UPDATE DATABASE", filesExistOnlyInStorage, async (e) => {
|
||||
// console.warn("UPDATE DATABASE", e);
|
||||
const file = storageFileNameMap[storageFileNameCI2CS[e]];
|
||||
if (!this.core.$$isFileSizeExceeded(file.stat.size)) {
|
||||
const path = file.path;
|
||||
await this.core.fileHandler.storeFileToDB(file);
|
||||
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(path, true));
|
||||
eventHub.emitEvent("event-file-changed", { file: path, automated: true });
|
||||
} else {
|
||||
this._log(`UPDATE DATABASE: ${e} has been skipped due to file size exceeding the limit`, logLevel);
|
||||
}
|
||||
}));
|
||||
initProcess.push(runAll("UPDATE STORAGE", filesExistOnlyInDatabase, async (e) => {
|
||||
const w = databaseFileNameMap[databaseFileNameCI2CS[e]];
|
||||
const path = getPath(w) ?? e;
|
||||
if (w && !(w.deleted || w._deleted)) {
|
||||
if (!this.core.$$isFileSizeExceeded(w.size)) {
|
||||
// await this.pullFile(path, undefined, false, undefined, false);
|
||||
// Memo: No need to force
|
||||
await this.core.fileHandler.dbToStorage(path, null, true);
|
||||
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(e, true));
|
||||
eventHub.emitEvent("event-file-changed", {
|
||||
file: e, automated: true
|
||||
});
|
||||
this._log(`Check or pull from db:${path} OK`);
|
||||
updateLog(procedureName, msg);
|
||||
};
|
||||
initProcess.push(
|
||||
runAll("UPDATE DATABASE", filesExistOnlyInStorage, async (e) => {
|
||||
// Exists in storage but not in database.
|
||||
const file = storageFileNameMap[storageFileNameCI2CS[e]];
|
||||
if (!this.core.$$isFileSizeExceeded(file.stat.size)) {
|
||||
const path = file.path;
|
||||
await this.core.fileHandler.storeFileToDB(file);
|
||||
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(path, true));
|
||||
eventHub.emitEvent("event-file-changed", { file: path, automated: true });
|
||||
} else {
|
||||
this._log(`UPDATE STORAGE: ${path} has been skipped due to file size exceeding the limit`, logLevel);
|
||||
this._log(`UPDATE DATABASE: ${e} has been skipped due to file size exceeding the limit`, logLevel);
|
||||
}
|
||||
} else if (w) {
|
||||
this._log(`Deletion history skipped: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
} else {
|
||||
this._log(`entry not found: ${path}`);
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
initProcess.push(
|
||||
runAll("UPDATE STORAGE", filesExistOnlyInDatabase, async (e) => {
|
||||
const w = databaseFileNameMap[databaseFileNameCI2CS[e]];
|
||||
// Exists in database but not in storage.
|
||||
const path = getPath(w) ?? e;
|
||||
if (w && !(w.deleted || w._deleted)) {
|
||||
if (!this.core.$$isFileSizeExceeded(w.size)) {
|
||||
// Prevent applying the conflicted state to the storage.
|
||||
if (w._conflicts?.length ?? 0 > 0) {
|
||||
this._log(`UPDATE STORAGE: ${path} has conflicts. skipped (x)`, LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
// await this.pullFile(path, undefined, false, undefined, false);
|
||||
// Memo: No need to force
|
||||
await this.core.fileHandler.dbToStorage(path, null, true);
|
||||
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(e, true));
|
||||
eventHub.emitEvent("event-file-changed", {
|
||||
file: e,
|
||||
automated: true,
|
||||
});
|
||||
this._log(`Check or pull from db:${path} OK`);
|
||||
} else {
|
||||
this._log(
|
||||
`UPDATE STORAGE: ${path} has been skipped due to file size exceeding the limit`,
|
||||
logLevel
|
||||
);
|
||||
}
|
||||
} else if (w) {
|
||||
this._log(`Deletion history skipped: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
} else {
|
||||
this._log(`entry not found: ${path}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const fileMap = filesExistBoth.map(path => {
|
||||
const fileMap = filesExistBoth.map((path) => {
|
||||
const file = storageFileNameMap[storageFileNameCI2CS[path]];
|
||||
const doc = databaseFileNameMap[databaseFileNameCI2CS[path]];
|
||||
return { file, doc }
|
||||
})
|
||||
initProcess.push(runAll("SYNC DATABASE AND STORAGE", fileMap, async (e) => {
|
||||
const { file, doc } = e;
|
||||
if (!this.core.$$isFileSizeExceeded(file.stat.size) && !this.core.$$isFileSizeExceeded(doc.size)) {
|
||||
await this.syncFileBetweenDBandStorage(file, doc);
|
||||
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(getPath(doc), true));
|
||||
eventHub.emitEvent("event-file-changed", {
|
||||
file: getPath(doc), automated: true
|
||||
});
|
||||
} else {
|
||||
this._log(`SYNC DATABASE AND STORAGE: ${getPath(doc)} has been skipped due to file size exceeding the limit`, logLevel);
|
||||
}
|
||||
}))
|
||||
return { file, doc };
|
||||
});
|
||||
initProcess.push(
|
||||
runAll("SYNC DATABASE AND STORAGE", fileMap, async (e) => {
|
||||
const { file, doc } = e;
|
||||
// Prevent applying the conflicted state to the storage.
|
||||
if (doc._conflicts?.length ?? 0 > 0) {
|
||||
this._log(`SYNC DATABASE AND STORAGE: ${file.path} has conflicts. skipped`, LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
if (!this.core.$$isFileSizeExceeded(file.stat.size) && !this.core.$$isFileSizeExceeded(doc.size)) {
|
||||
await this.syncFileBetweenDBandStorage(file, doc);
|
||||
} else {
|
||||
this._log(
|
||||
`SYNC DATABASE AND STORAGE: ${getPath(doc)} has been skipped due to file size exceeding the limit`,
|
||||
logLevel
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(initProcess);
|
||||
|
||||
// this.setStatusBarText(`NOW TRACKING!`);
|
||||
this._log("Initialized, NOW TRACKING!");
|
||||
if (!isInitialized) {
|
||||
await (this.core.kvDB.set("initialized", true))
|
||||
await this.core.kvDB.set("initialized", true);
|
||||
}
|
||||
if (showingNotice) {
|
||||
this._log("Initialize done!", LOG_LEVEL_NOTICE, "syncAll");
|
||||
@@ -222,14 +265,14 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
|
||||
async syncFileBetweenDBandStorage(file: UXFileInfoStub, doc: MetaEntry) {
|
||||
if (!doc) {
|
||||
throw new Error(`Missing doc:${(file as any).path}`)
|
||||
throw new Error(`Missing doc:${(file as any).path}`);
|
||||
}
|
||||
if ("path" in file) {
|
||||
const w = this.core.storageAccess.getFileStub((file as any).path);
|
||||
if (w) {
|
||||
file = w;
|
||||
} else {
|
||||
throw new Error(`Missing file:${(file as any).path}`)
|
||||
throw new Error(`Missing file:${(file as any).path}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,22 +282,30 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
if (!this.core.$$isFileSizeExceeded(file.stat.size)) {
|
||||
this._log("STORAGE -> DB :" + file.path);
|
||||
await this.core.fileHandler.storeFileToDB(file);
|
||||
eventHub.emitEvent("event-file-changed", {
|
||||
file: file.path, automated: true
|
||||
});
|
||||
} else {
|
||||
this._log(`STORAGE -> DB : ${file.path} has been skipped due to file size exceeding the limit`, LOG_LEVEL_NOTICE);
|
||||
this._log(
|
||||
`STORAGE -> DB : ${file.path} has been skipped due to file size exceeding the limit`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
break;
|
||||
case TARGET_IS_NEW:
|
||||
if (!this.core.$$isFileSizeExceeded(doc.size)) {
|
||||
this._log("STORAGE <- DB :" + file.path);
|
||||
if (!await this.core.fileHandler.dbToStorage(doc, stripAllPrefixes(file.path), true)) {
|
||||
if (await this.core.fileHandler.dbToStorage(doc, stripAllPrefixes(file.path), true)) {
|
||||
eventHub.emitEvent("event-file-changed", {
|
||||
file: file.path,
|
||||
automated: true,
|
||||
});
|
||||
} else {
|
||||
this._log(`STORAGE <- DB : Cloud not read ${file.path}, possibly deleted`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return caches;
|
||||
} else {
|
||||
this._log(`STORAGE <- DB : ${file.path} has been skipped due to file size exceeding the limit`, LOG_LEVEL_NOTICE);
|
||||
this._log(
|
||||
`STORAGE <- DB : ${file.path} has been skipped due to file size exceeding the limit`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
break;
|
||||
case EVEN:
|
||||
@@ -263,31 +314,29 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
default:
|
||||
this._log("STORAGE ?? DB :" + file.path + " Something got weird");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// This method uses an old version of database accessor, which is not recommended.
|
||||
// TODO: Fix
|
||||
async collectDeletedFiles() {
|
||||
const limitDays = this.settings.automaticallyDeleteMetadataOfDeletedFiles;
|
||||
if (limitDays <= 0) return;
|
||||
this._log(`Checking expired file history`);
|
||||
const limit = Date.now() - (86400 * 1000 * limitDays);
|
||||
const limit = Date.now() - 86400 * 1000 * limitDays;
|
||||
const notes: {
|
||||
path: string,
|
||||
mtime: number,
|
||||
ttl: number,
|
||||
doc: PouchDB.Core.ExistingDocument<EntryDoc & PouchDB.Core.AllDocsMeta>
|
||||
path: string;
|
||||
mtime: number;
|
||||
ttl: number;
|
||||
doc: PouchDB.Core.ExistingDocument<EntryDoc & PouchDB.Core.AllDocsMeta>;
|
||||
}[] = [];
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (isAnyNote(doc)) {
|
||||
if (doc.deleted && (doc.mtime - limit) < 0) {
|
||||
if (doc.deleted && doc.mtime - limit < 0) {
|
||||
notes.push({
|
||||
path: getPath(doc),
|
||||
mtime: doc.mtime,
|
||||
ttl: (doc.mtime - limit) / 1000 / 86400,
|
||||
doc: doc
|
||||
doc: doc,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -307,22 +356,22 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
}
|
||||
|
||||
async $$initializeDatabase(showingNotice: boolean = false, reopenDatabase = true): Promise<boolean> {
|
||||
this.core.isReady = false;
|
||||
if ((!reopenDatabase) || await this.core.$$openDatabase()) {
|
||||
this.core.$$resetIsReady();
|
||||
if (!reopenDatabase || (await this.core.$$openDatabase())) {
|
||||
if (this.localDatabase.isReady) {
|
||||
await this.core.$$performFullScan(showingNotice);
|
||||
}
|
||||
if (!await this.core.$everyOnDatabaseInitialized(showingNotice)) {
|
||||
if (!(await this.core.$everyOnDatabaseInitialized(showingNotice))) {
|
||||
this._log(`Initializing database has been failed on some module`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
this.core.isReady = true;
|
||||
this.core.$$markIsReady();
|
||||
// run queued event once.
|
||||
await this.core.$everyCommitPendingFileEvent();
|
||||
return true;
|
||||
} else {
|
||||
this.core.isReady = false;
|
||||
this.core.$$resetIsReady();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
|
||||
export class ModuleKeyValueDB extends AbstractModule implements ICoreModule {
|
||||
|
||||
tryCloseKvDB() {
|
||||
try {
|
||||
this.core.kvDB?.close();
|
||||
@@ -42,7 +41,7 @@ export class ModuleKeyValueDB extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
|
||||
async $everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
if (!await this.openKeyValueDB()) {
|
||||
if (!(await this.openKeyValueDB())) {
|
||||
return false;
|
||||
}
|
||||
this.core.simpleStore = this.core.$$getSimpleStore<any>("os");
|
||||
@@ -60,11 +59,21 @@ export class ModuleKeyValueDB extends AbstractModule implements ICoreModule {
|
||||
delete: async (key: string): Promise<void> => {
|
||||
await this.core.kvDB.del(`${prefix}${key}`);
|
||||
},
|
||||
keys: async (from: string | undefined, to: string | undefined, count?: number | undefined): Promise<string[]> => {
|
||||
const ret = this.core.kvDB.keys(IDBKeyRange.bound(`${prefix}${from || ""}`, `${prefix}${to || ""}`), count);
|
||||
return (await ret).map(e => e.toString()).filter(e => e.startsWith(prefix)).map(e => e.substring(prefix.length));
|
||||
}
|
||||
}
|
||||
keys: async (
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
count?: number | undefined
|
||||
): Promise<string[]> => {
|
||||
const ret = this.core.kvDB.keys(
|
||||
IDBKeyRange.bound(`${prefix}${from || ""}`, `${prefix}${to || ""}`),
|
||||
count
|
||||
);
|
||||
return (await ret)
|
||||
.map((e) => e.toString())
|
||||
.filter((e) => e.startsWith(prefix))
|
||||
.map((e) => e.substring(prefix.length));
|
||||
},
|
||||
};
|
||||
}
|
||||
$everyOnInitializeDatabase(db: LiveSyncLocalDB): Promise<boolean> {
|
||||
return this.openKeyValueDB();
|
||||
@@ -72,7 +81,7 @@ export class ModuleKeyValueDB extends AbstractModule implements ICoreModule {
|
||||
|
||||
async $everyOnResetDatabase(db: LiveSyncLocalDB): Promise<boolean> {
|
||||
try {
|
||||
const kvDBKey = "queued-files"
|
||||
const kvDBKey = "queued-files";
|
||||
await this.core.kvDB.del(kvDBKey);
|
||||
// localStorage.removeItem(lsKey);
|
||||
await this.core.kvDB.destroy();
|
||||
@@ -87,4 +96,4 @@ export class ModuleKeyValueDB extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,210 +1,376 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from 'octagonal-wheels/common/logger.js';
|
||||
import { SETTING_VERSION_SUPPORT_CASE_INSENSITIVE } from '../../lib/src/common/types.js';
|
||||
import { EVENT_REQUEST_OPEN_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI, eventHub } from '../../common/events.ts';
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger.js";
|
||||
import { type ObsidianLiveSyncSettings } from "../../lib/src/common/types.js";
|
||||
import {
|
||||
EVENT_REQUEST_OPEN_P2P,
|
||||
EVENT_REQUEST_OPEN_SETTING_WIZARD,
|
||||
EVENT_REQUEST_OPEN_SETTINGS,
|
||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
||||
EVENT_REQUEST_RUN_DOCTOR,
|
||||
eventHub,
|
||||
} from "../../common/events.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import { checkUnsuitableValues, RuleLevel, type RuleForType } from "../../lib/src/common/configForDoc.ts";
|
||||
import { getConfName, type AllSettingItemKey } from "../features/SettingDialogue/settingConstants.ts";
|
||||
|
||||
export class ModuleMigration extends AbstractModule implements ICoreModule {
|
||||
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
|
||||
const r = checkUnsuitableValues(this.core.settings);
|
||||
if (!forceRescan && r.version == this.settings.doctorProcessedVersion) {
|
||||
const isIssueFound = Object.keys(r.rules).length > 0;
|
||||
const msg = isIssueFound ? "Issues found" : "No issues found";
|
||||
this._log(`${msg} but marked as to be silent`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const issues = Object.entries(r.rules);
|
||||
if (issues.length == 0) {
|
||||
this._log(
|
||||
$msg("Doctor.Message.NoIssues"),
|
||||
activateReason !== "updated" ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
const OPT_YES = `${$msg("Doctor.Button.Yes")}` as const;
|
||||
const OPT_NO = `${$msg("Doctor.Button.No")}` as const;
|
||||
const OPT_DISMISS = `${$msg("Doctor.Button.DismissThisVersion")}` as const;
|
||||
// this._log(`Issues found in ${key}`, LOG_LEVEL_VERBOSE);
|
||||
const issues = Object.keys(r.rules)
|
||||
.map((key) => `- ${getConfName(key as AllSettingItemKey)}`)
|
||||
.join("\n");
|
||||
const msg = await this.core.confirm.askSelectStringDialogue(
|
||||
$msg("Doctor.Dialogue.Main", { activateReason, issues }),
|
||||
[OPT_YES, OPT_NO, OPT_DISMISS],
|
||||
{
|
||||
title: $msg("Doctor.Dialogue.Title"),
|
||||
defaultAction: OPT_YES,
|
||||
}
|
||||
);
|
||||
if (msg == OPT_DISMISS) {
|
||||
this.settings.doctorProcessedVersion = r.version;
|
||||
await this.core.saveSettings();
|
||||
this._log("Marked as to be silent", LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (msg != OPT_YES) return;
|
||||
let shouldRebuild = false;
|
||||
let shouldRebuildLocal = false;
|
||||
const issueItems = Object.entries(r.rules) as [keyof ObsidianLiveSyncSettings, RuleForType<any>][];
|
||||
this._log(`${issueItems.length} Issue(s) found `, LOG_LEVEL_VERBOSE);
|
||||
let idx = 0;
|
||||
const applySettings = {} as Partial<ObsidianLiveSyncSettings>;
|
||||
const OPT_FIX = `${$msg("Doctor.Button.Fix")}` as const;
|
||||
const OPT_SKIP = `${$msg("Doctor.Button.Skip")}` as const;
|
||||
const OPT_FIXBUTNOREBUILD = `${$msg("Doctor.Button.FixButNoRebuild")}` as const;
|
||||
let skipped = 0;
|
||||
for (const [key, value] of issueItems) {
|
||||
const levelMap = {
|
||||
[RuleLevel.Necessary]: $msg("Doctor.Level.Necessary"),
|
||||
[RuleLevel.Recommended]: $msg("Doctor.Level.Recommended"),
|
||||
[RuleLevel.Optional]: $msg("Doctor.Level.Optional"),
|
||||
[RuleLevel.Must]: $msg("Doctor.Level.Must"),
|
||||
};
|
||||
const level = value.level ? levelMap[value.level] : "Unknown";
|
||||
const options = [OPT_FIX] as [typeof OPT_FIX | typeof OPT_SKIP | typeof OPT_FIXBUTNOREBUILD];
|
||||
if ((!skipRebuild && value.requireRebuild) || value.requireRebuildLocal) {
|
||||
options.push(OPT_FIXBUTNOREBUILD);
|
||||
}
|
||||
options.push(OPT_SKIP);
|
||||
const note = skipRebuild
|
||||
? ""
|
||||
: `${value.requireRebuild ? $msg("Doctor.Message.RebuildRequired") : ""}${value.requireRebuildLocal ? $msg("Doctor.Message.RebuildLocalRequired") : ""}`;
|
||||
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
$msg("Doctor.Dialogue.MainFix", {
|
||||
name: getConfName(key as AllSettingItemKey),
|
||||
current: `${this.settings[key]}`,
|
||||
reason: value.reason ?? " N/A ",
|
||||
ideal: `${value.value}`,
|
||||
level: `${level}`,
|
||||
note: note,
|
||||
}),
|
||||
options,
|
||||
{
|
||||
title: $msg("Doctor.Dialogue.TitleFix", { current: `${++idx}`, total: `${issueItems.length}` }),
|
||||
defaultAction: OPT_FIX,
|
||||
}
|
||||
);
|
||||
|
||||
if (ret == OPT_FIX || ret == OPT_FIXBUTNOREBUILD) {
|
||||
//@ts-ignore
|
||||
applySettings[key] = value.value;
|
||||
if (ret == OPT_FIX) {
|
||||
shouldRebuild = shouldRebuild || value.requireRebuild || false;
|
||||
shouldRebuildLocal = shouldRebuildLocal || value.requireRebuildLocal || false;
|
||||
}
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
if (Object.keys(applySettings).length > 0) {
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
...applySettings,
|
||||
};
|
||||
}
|
||||
if (skipped == 0) {
|
||||
this.settings.doctorProcessedVersion = r.version;
|
||||
} else {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog($msg("Doctor.Message.SomeSkipped"), {
|
||||
title: $msg("Doctor.Dialogue.TitleAlmostDone"),
|
||||
defaultOption: "No",
|
||||
})) == "no"
|
||||
) {
|
||||
// Some skipped, and user wants
|
||||
this.settings.doctorProcessedVersion = r.version;
|
||||
}
|
||||
}
|
||||
await this.core.saveSettings();
|
||||
if (!skipRebuild) {
|
||||
if (shouldRebuild) {
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
await this.core.$$performRestart();
|
||||
} else if (shouldRebuildLocal) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
await this.core.$$performRestart();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async migrateDisableBulkSend() {
|
||||
if (this.settings.sendChunksBulk) {
|
||||
this._log("Send chunks in bulk has been enabled, however, this feature had been corrupted. Sorry for your inconvenience. Automatically disabled.", LOG_LEVEL_NOTICE);
|
||||
this._log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
|
||||
this.settings.sendChunksBulk = false;
|
||||
this.settings.sendChunksBulkMaxSize = 1;
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
async migrationCheck() {
|
||||
const old = this.settings.settingVersion;
|
||||
const current = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
// Check each migrations(old -> current)
|
||||
if (!await this.migrateToCaseInsensitive(old, current)) {
|
||||
this._log(`Migration failed or cancelled from ${old} to ${current}`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
// async migrationCheck() {
|
||||
// const old = this.settings.settingVersion;
|
||||
// const current = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
// // Check each migrations(old -> current)
|
||||
// if (!(await this.migrateToCaseInsensitive(old, current))) {
|
||||
// this._log(
|
||||
// $msg("moduleMigration.logMigrationFailed", {
|
||||
// old: old.toString(),
|
||||
// current: current.toString(),
|
||||
// }),
|
||||
// LOG_LEVEL_NOTICE
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
// async migrateToCaseInsensitive(old: number, current: number) {
|
||||
// if (
|
||||
// this.settings.handleFilenameCaseSensitive !== undefined &&
|
||||
// this.settings.doNotUseFixedRevisionForChunks !== undefined
|
||||
// ) {
|
||||
// if (current < SETTING_VERSION_SUPPORT_CASE_INSENSITIVE) {
|
||||
// this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
// await this.saveSettings();
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
// if (
|
||||
// old >= SETTING_VERSION_SUPPORT_CASE_INSENSITIVE &&
|
||||
// this.settings.handleFilenameCaseSensitive !== undefined &&
|
||||
// this.settings.doNotUseFixedRevisionForChunks !== undefined
|
||||
// ) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// let remoteHandleFilenameCaseSensitive: undefined | boolean = undefined;
|
||||
// let remoteDoNotUseFixedRevisionForChunks: undefined | boolean = undefined;
|
||||
// let remoteChecked = false;
|
||||
// try {
|
||||
// const remoteInfo = await this.core.replicator.getRemotePreferredTweakValues(this.settings);
|
||||
// if (remoteInfo) {
|
||||
// remoteHandleFilenameCaseSensitive =
|
||||
// "handleFilenameCaseSensitive" in remoteInfo ? remoteInfo.handleFilenameCaseSensitive : false;
|
||||
// remoteDoNotUseFixedRevisionForChunks =
|
||||
// "doNotUseFixedRevisionForChunks" in remoteInfo ? remoteInfo.doNotUseFixedRevisionForChunks : false;
|
||||
// if (
|
||||
// remoteHandleFilenameCaseSensitive !== undefined ||
|
||||
// remoteDoNotUseFixedRevisionForChunks !== undefined
|
||||
// ) {
|
||||
// remoteChecked = true;
|
||||
// }
|
||||
// } else {
|
||||
// this._log($msg("moduleMigration.logFetchRemoteTweakFailed"), LOG_LEVEL_INFO);
|
||||
// }
|
||||
// } catch (ex) {
|
||||
// this._log($msg("moduleMigration.logRemoteTweakUnavailable"), LOG_LEVEL_INFO);
|
||||
// this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
// }
|
||||
|
||||
// if (remoteChecked) {
|
||||
// // The case that the remote could be checked.
|
||||
// if (remoteHandleFilenameCaseSensitive && remoteDoNotUseFixedRevisionForChunks) {
|
||||
// // Migrated, but configured as same as old behaviour.
|
||||
// this.settings.handleFilenameCaseSensitive = true;
|
||||
// this.settings.doNotUseFixedRevisionForChunks = true;
|
||||
// this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
// this._log(
|
||||
// $msg("moduleMigration.logMigratedSameBehaviour", {
|
||||
// current: current.toString(),
|
||||
// }),
|
||||
// LOG_LEVEL_INFO
|
||||
// );
|
||||
// await this.saveSettings();
|
||||
// return true;
|
||||
// }
|
||||
// const message = $msg("moduleMigration.msgFetchRemoteAgain");
|
||||
// const OPTION_FETCH = $msg("moduleMigration.optionYesFetchAgain");
|
||||
// const DISMISS = $msg("moduleMigration.optionNoAskAgain");
|
||||
// const options = [OPTION_FETCH, DISMISS];
|
||||
// const ret = await this.core.confirm.confirmWithMessage(
|
||||
// $msg("moduleMigration.titleCaseSensitivity"),
|
||||
// message,
|
||||
// options,
|
||||
// DISMISS,
|
||||
// 40
|
||||
// );
|
||||
// if (ret == OPTION_FETCH) {
|
||||
// this.settings.handleFilenameCaseSensitive = remoteHandleFilenameCaseSensitive || false;
|
||||
// this.settings.doNotUseFixedRevisionForChunks = remoteDoNotUseFixedRevisionForChunks || false;
|
||||
// this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
// await this.saveSettings();
|
||||
// try {
|
||||
// await this.core.rebuilder.scheduleFetch();
|
||||
// return;
|
||||
// } catch (ex) {
|
||||
// this._log($msg("moduleMigration.logRedflag2CreationFail"), LOG_LEVEL_VERBOSE);
|
||||
// this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
// }
|
||||
// return false;
|
||||
// } else {
|
||||
// return false;
|
||||
// }
|
||||
// }
|
||||
|
||||
// const ENABLE_BOTH = $msg("moduleMigration.optionEnableBoth");
|
||||
// const ENABLE_FILENAME_CASE_INSENSITIVE = $msg("moduleMigration.optionEnableFilenameCaseInsensitive");
|
||||
// const ENABLE_FIXED_REVISION_FOR_CHUNKS = $msg("moduleMigration.optionEnableFixedRevisionForChunks");
|
||||
// const ADJUST_TO_REMOTE = $msg("moduleMigration.optionAdjustRemote");
|
||||
// const KEEP = $msg("moduleMigration.optionKeepPreviousBehaviour");
|
||||
// const DISMISS = $msg("moduleMigration.optionDecideLater");
|
||||
// const message = $msg("moduleMigration.msgSinceV02321");
|
||||
// const options = [ENABLE_BOTH, ENABLE_FILENAME_CASE_INSENSITIVE, ENABLE_FIXED_REVISION_FOR_CHUNKS];
|
||||
// if (remoteChecked) {
|
||||
// options.push(ADJUST_TO_REMOTE);
|
||||
// }
|
||||
// options.push(KEEP, DISMISS);
|
||||
// const ret = await this.core.confirm.confirmWithMessage(
|
||||
// $msg("moduleMigration.titleCaseSensitivity"),
|
||||
// message,
|
||||
// options,
|
||||
// DISMISS,
|
||||
// 40
|
||||
// );
|
||||
// console.dir(ret);
|
||||
// switch (ret) {
|
||||
// case ENABLE_BOTH:
|
||||
// this.settings.handleFilenameCaseSensitive = false;
|
||||
// this.settings.doNotUseFixedRevisionForChunks = false;
|
||||
// break;
|
||||
// case ENABLE_FILENAME_CASE_INSENSITIVE:
|
||||
// this.settings.handleFilenameCaseSensitive = false;
|
||||
// this.settings.doNotUseFixedRevisionForChunks = true;
|
||||
// break;
|
||||
// case ENABLE_FIXED_REVISION_FOR_CHUNKS:
|
||||
// this.settings.doNotUseFixedRevisionForChunks = false;
|
||||
// this.settings.handleFilenameCaseSensitive = true;
|
||||
// break;
|
||||
// case KEEP:
|
||||
// this.settings.handleFilenameCaseSensitive = true;
|
||||
// this.settings.doNotUseFixedRevisionForChunks = true;
|
||||
// this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
// await this.saveSettings();
|
||||
// return true;
|
||||
// case DISMISS:
|
||||
// default:
|
||||
// return false;
|
||||
// }
|
||||
// this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
// await this.saveSettings();
|
||||
// await this.core.rebuilder.scheduleRebuild();
|
||||
// await this.core.$$performRestart();
|
||||
// }
|
||||
|
||||
async initialMessage() {
|
||||
const message = $msg("moduleMigration.msgInitialSetup", {
|
||||
URI_DOC: $msg("moduleMigration.docUri"),
|
||||
});
|
||||
const USE_SETUP = $msg("moduleMigration.optionHaveSetupUri");
|
||||
const NEXT = $msg("moduleMigration.optionNoSetupUri");
|
||||
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message, [USE_SETUP, NEXT], {
|
||||
title: $msg("moduleMigration.titleWelcome"),
|
||||
defaultAction: USE_SETUP,
|
||||
});
|
||||
if (ret === USE_SETUP) {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETUP_URI);
|
||||
return false;
|
||||
} else if (ret == NEXT) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async migrateToCaseInsensitive(old: number, current: number) {
|
||||
if (this.settings.handleFilenameCaseSensitive !== undefined && this.settings.doNotUseFixedRevisionForChunks !== undefined) {
|
||||
if (current < SETTING_VERSION_SUPPORT_CASE_INSENSITIVE) {
|
||||
this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
await this.saveSettings();
|
||||
}
|
||||
return true;
|
||||
|
||||
async askAgainForSetupURI() {
|
||||
const message = $msg("moduleMigration.msgRecommendSetupUri", { URI_DOC: $msg("moduleMigration.docUri") });
|
||||
const USE_MINIMAL = $msg("moduleMigration.optionSetupWizard");
|
||||
const USE_P2P = $msg("moduleMigration.optionSetupViaP2P");
|
||||
const USE_SETUP = $msg("moduleMigration.optionManualSetup");
|
||||
const NEXT = $msg("moduleMigration.optionRemindNextLaunch");
|
||||
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message, [USE_MINIMAL, USE_SETUP, USE_P2P, NEXT], {
|
||||
title: $msg("moduleMigration.titleRecommendSetupUri"),
|
||||
defaultAction: USE_MINIMAL,
|
||||
});
|
||||
if (ret === USE_MINIMAL) {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD);
|
||||
return false;
|
||||
}
|
||||
if (old >= SETTING_VERSION_SUPPORT_CASE_INSENSITIVE && this.settings.handleFilenameCaseSensitive !== undefined && this.settings.doNotUseFixedRevisionForChunks !== undefined) {
|
||||
return true;
|
||||
if (ret === USE_P2P) {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P);
|
||||
return false;
|
||||
}
|
||||
|
||||
let remoteHandleFilenameCaseSensitive: undefined | boolean = undefined;
|
||||
let remoteDoNotUseFixedRevisionForChunks: undefined | boolean = undefined;
|
||||
let remoteChecked = false;
|
||||
try {
|
||||
const remoteInfo = await this.core.replicator.getRemotePreferredTweakValues(this.settings);
|
||||
if (remoteInfo) {
|
||||
remoteHandleFilenameCaseSensitive = "handleFilenameCaseSensitive" in remoteInfo ? remoteInfo.handleFilenameCaseSensitive : false;
|
||||
remoteDoNotUseFixedRevisionForChunks = "doNotUseFixedRevisionForChunks" in remoteInfo ? remoteInfo.doNotUseFixedRevisionForChunks : false;
|
||||
if (remoteHandleFilenameCaseSensitive !== undefined || remoteDoNotUseFixedRevisionForChunks !== undefined) {
|
||||
remoteChecked = true;
|
||||
}
|
||||
} else {
|
||||
this._log("Failed to fetch remote tweak values", LOG_LEVEL_INFO);
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log("Could not get remote tweak values", LOG_LEVEL_INFO);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (ret === USE_SETUP) {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTINGS);
|
||||
return false;
|
||||
} else if (ret == NEXT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (remoteChecked) {
|
||||
// The case that the remote could be checked.
|
||||
if (remoteHandleFilenameCaseSensitive && remoteDoNotUseFixedRevisionForChunks) {
|
||||
// Migrated, but configured as same as old behaviour.
|
||||
this.settings.handleFilenameCaseSensitive = true;
|
||||
this.settings.doNotUseFixedRevisionForChunks = true;
|
||||
this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
this._log(`Migrated to db:${current} with the same behaviour as before`, LOG_LEVEL_INFO);
|
||||
await this.saveSettings();
|
||||
return true;
|
||||
}
|
||||
const message = `As you may already know, the self-hosted LiveSync has changed its default behaviour and database structure.
|
||||
|
||||
And thankfully, with your time and efforts, the remote database appears to have already been migrated. Congratulations!
|
||||
|
||||
However, we need a bit more. The configuration of this device is not compatible with the remote database. We will need to fetch the remote database again. Should we fetch from the remote again now?
|
||||
|
||||
___Note: We cannot synchronise until the configuration has been changed and the database has been fetched again.___
|
||||
___Note2: The chunks are completely immutable, we can fetch only the metadata and difference.___
|
||||
`;
|
||||
const OPTION_FETCH = "Yes, fetch again";
|
||||
const DISMISS = "No, please ask again";
|
||||
const options = [OPTION_FETCH, DISMISS];
|
||||
const ret = await this.core.confirm.confirmWithMessage("Case Sensitivity", message, options, "No, please ask again", 40);
|
||||
if (ret == OPTION_FETCH) {
|
||||
this.settings.handleFilenameCaseSensitive = remoteHandleFilenameCaseSensitive || false;
|
||||
this.settings.doNotUseFixedRevisionForChunks = remoteDoNotUseFixedRevisionForChunks || false;
|
||||
this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
await this.saveSettings();
|
||||
try {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
return;
|
||||
} catch (ex) {
|
||||
this._log("Failed to create redflag2", LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const ENABLE_BOTH = "Enable both";
|
||||
const ENABLE_FILENAME_CASE_INSENSITIVE = "Enable only #1";
|
||||
const ENABLE_FIXED_REVISION_FOR_CHUNKS = "Enable only #2";
|
||||
const ADJUST_TO_REMOTE = "Adjust to remote";
|
||||
const DISMISS = "Decide it later";
|
||||
const KEEP = "Keep previous behaviour";
|
||||
const message = `Since v0.23.21, the self-hosted LiveSync has changed the default behaviour and database structure. The following changes have been made:
|
||||
|
||||
1. **Case sensitivity of filenames**
|
||||
The handling of filenames is now case-insensitive. This is a beneficial change for most platforms, other than Linux and iOS, which do not manage filename case sensitivity effectively.
|
||||
(On These, a warning will be displayed for files with the same name but different cases).
|
||||
|
||||
2. **Revision handling of the chunks**
|
||||
Chunks are immutable, which allows their revisions to be fixed. This change will enhance the performance of file saving.
|
||||
|
||||
___However, to enable either of these changes, both remote and local databases need to be rebuilt. This process takes a few minutes, and we recommend doing it when you have ample time.___
|
||||
|
||||
- If you wish to maintain the previous behaviour, you can skip this process by using \`${KEEP}\`.
|
||||
- If you do not have enough time, please choose \`${DISMISS}\`. You will be prompted again later.
|
||||
- If you have rebuilt the database on another device, please select \`${DISMISS}\` and try synchronizing again. Since a difference has been detected, you will be prompted again.
|
||||
`;
|
||||
const options = [
|
||||
ENABLE_BOTH,
|
||||
ENABLE_FILENAME_CASE_INSENSITIVE,
|
||||
ENABLE_FIXED_REVISION_FOR_CHUNKS];
|
||||
if (remoteChecked) {
|
||||
options.push(ADJUST_TO_REMOTE);
|
||||
}
|
||||
options.push(KEEP, DISMISS);
|
||||
const ret = await this.core.confirm.confirmWithMessage("Case Sensitivity", message, options, DISMISS, 40);
|
||||
console.dir(ret);
|
||||
switch (ret) {
|
||||
case ENABLE_BOTH:
|
||||
this.settings.handleFilenameCaseSensitive = false;
|
||||
this.settings.doNotUseFixedRevisionForChunks = false;
|
||||
break;
|
||||
case ENABLE_FILENAME_CASE_INSENSITIVE:
|
||||
this.settings.handleFilenameCaseSensitive = false;
|
||||
this.settings.doNotUseFixedRevisionForChunks = true;
|
||||
break;
|
||||
case ENABLE_FIXED_REVISION_FOR_CHUNKS:
|
||||
this.settings.doNotUseFixedRevisionForChunks = false;
|
||||
this.settings.handleFilenameCaseSensitive = true;
|
||||
break;
|
||||
case KEEP:
|
||||
this.settings.handleFilenameCaseSensitive = true;
|
||||
this.settings.doNotUseFixedRevisionForChunks = true;
|
||||
this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
await this.saveSettings();
|
||||
return true;
|
||||
case DISMISS:
|
||||
default:
|
||||
return false;
|
||||
|
||||
}
|
||||
this.settings.settingVersion = SETTING_VERSION_SUPPORT_CASE_INSENSITIVE;
|
||||
await this.saveSettings();
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
await this.core.$$performRestart();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async $everyOnFirstInitialize(): Promise<boolean> {
|
||||
if (!this.localDatabase.isReady) {
|
||||
this._log(`Something went wrong! The local database is not ready`, LOG_LEVEL_NOTICE);
|
||||
this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (this.settings.isConfigured) {
|
||||
await this.migrationCheck();
|
||||
await this.migrateUsingDoctor(false);
|
||||
// await this.migrationCheck();
|
||||
await this.migrateDisableBulkSend();
|
||||
}
|
||||
if (!this.settings.isConfigured) {
|
||||
// Case sensitivity
|
||||
const message = `Hello and welcome to Self-hosted LiveSync.
|
||||
|
||||
Your device seems to **not be configured yet**. Please finish the setup and synchronise your vaults!
|
||||
|
||||
Click anywhere to stop counting down.
|
||||
|
||||
## At the first device
|
||||
- With Setup URI -> Use \`Use the copied setup URI\`.
|
||||
If you have configured it automatically, you should have one.
|
||||
- Without Setup URI -> Use \`Setup wizard\` in setting dialogue. **\`Minimal setup\` is recommended**.
|
||||
- What is the Setup URI? -> Do not worry! We have [some docs](https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use) now. Please refer to them once.
|
||||
|
||||
## At the subsequent device
|
||||
- With Setup URI -> Use \`Use the copied setup URI\`.
|
||||
If you do not have it yet, you can copy it on the first device.
|
||||
- Without Setup URI -> Use \`Setup wizard\` in setting dialogue, but **strongly recommends using setup URI**.
|
||||
`
|
||||
const OPEN_SETUP = "Open setting dialog";
|
||||
const USE_SETUP = "Use the copied setup URI";
|
||||
const DISMISS = "Dismiss";
|
||||
|
||||
const ret = await this.core.confirm.confirmWithMessage("Welcome to Self-hosted LiveSync", message, [
|
||||
USE_SETUP, OPEN_SETUP, DISMISS], DISMISS, 40);
|
||||
if (ret === OPEN_SETUP) {
|
||||
try {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTINGS);
|
||||
} catch (ex) {
|
||||
this._log("Something went wrong on opening setting dialog, please open it manually", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} else if (ret == USE_SETUP) {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETUP_URI);
|
||||
if (!(await this.initialMessage()) || !(await this.askAgainForSetupURI())) {
|
||||
this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
await this.migrateUsingDoctor(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$everyOnLayoutReady(): Promise<boolean> {
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
|
||||
await this.migrateUsingDoctor(false, reason, true);
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// This file is based on a file that was published by the @remotely-save, under the Apache 2 License.
|
||||
// I would love to express my deepest gratitude to the original authors for their hard work and dedication. Without their contributions, this project would not have been possible.
|
||||
//
|
||||
//
|
||||
// Original Implementation is here: https://github.com/remotely-save/remotely-save/blob/28b99557a864ef59c19d2ad96101196e401718f0/src/remoteForS3.ts
|
||||
|
||||
import {
|
||||
FetchHttpHandler,
|
||||
type FetchHttpHandlerOptions,
|
||||
} from "@smithy/fetch-http-handler";
|
||||
import { FetchHttpHandler, type FetchHttpHandlerOptions } from "@smithy/fetch-http-handler";
|
||||
import { HttpRequest, HttpResponse, type HttpHandlerOptions } from "@smithy/protocol-http";
|
||||
//@ts-ignore
|
||||
import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout";
|
||||
@@ -25,20 +22,13 @@ import { requestUrl, type RequestUrlParam } from "../../../deps.ts";
|
||||
export class ObsHttpHandler extends FetchHttpHandler {
|
||||
requestTimeoutInMs: number | undefined;
|
||||
reverseProxyNoSignUrl: string | undefined;
|
||||
constructor(
|
||||
options?: FetchHttpHandlerOptions,
|
||||
reverseProxyNoSignUrl?: string
|
||||
) {
|
||||
constructor(options?: FetchHttpHandlerOptions, reverseProxyNoSignUrl?: string) {
|
||||
super(options);
|
||||
this.requestTimeoutInMs =
|
||||
options === undefined ? undefined : options.requestTimeout;
|
||||
this.requestTimeoutInMs = options === undefined ? undefined : options.requestTimeout;
|
||||
this.reverseProxyNoSignUrl = reverseProxyNoSignUrl;
|
||||
}
|
||||
// eslint-disable-next-line require-await
|
||||
async handle(
|
||||
request: HttpRequest,
|
||||
{ abortSignal }: HttpHandlerOptions = {}
|
||||
): Promise<{ response: HttpResponse }> {
|
||||
async handle(request: HttpRequest, { abortSignal }: HttpHandlerOptions = {}): Promise<{ response: HttpResponse }> {
|
||||
if (abortSignal?.aborted) {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
@@ -54,18 +44,13 @@ export class ObsHttpHandler extends FetchHttpHandler {
|
||||
}
|
||||
|
||||
const { port, method } = request;
|
||||
let url = `${request.protocol}//${request.hostname}${port ? `:${port}` : ""
|
||||
}${path}`;
|
||||
if (
|
||||
this.reverseProxyNoSignUrl !== undefined &&
|
||||
this.reverseProxyNoSignUrl !== ""
|
||||
) {
|
||||
let url = `${request.protocol}//${request.hostname}${port ? `:${port}` : ""}${path}`;
|
||||
if (this.reverseProxyNoSignUrl !== undefined && this.reverseProxyNoSignUrl !== "") {
|
||||
const urlObj = new URL(url);
|
||||
urlObj.host = this.reverseProxyNoSignUrl;
|
||||
url = urlObj.href;
|
||||
}
|
||||
const body =
|
||||
method === "GET" || method === "HEAD" ? undefined : request.body;
|
||||
const body = method === "GET" || method === "HEAD" ? undefined : request.body;
|
||||
|
||||
const transformedHeaders: Record<string, string> = {};
|
||||
for (const key of Object.keys(request.headers)) {
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { AbstractObsidianModule, type IObsidianModule } from '../AbstractObsidianModule.ts';
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_VERBOSE } from 'octagonal-wheels/common/logger';
|
||||
import { Notice, requestUrl, type RequestUrlParam, type RequestUrlResponse } from '../../deps.ts';
|
||||
import { type EntryDoc, type FilePathWithPrefix } from '../../lib/src/common/types.ts';
|
||||
import { getPathFromTFile } from '../../common/utils.ts';
|
||||
import { disableEncryption, enableEncryption, isCloudantURI, isValidRemoteCouchDBURI, replicationFilter } from '../../lib/src/pouchdb/utils_couchdb.ts';
|
||||
import { setNoticeClass } from '../../lib/src/mock_and_interop/wrapper.ts';
|
||||
import { ObsHttpHandler } from './APILib/ObsHttpHandler.ts';
|
||||
import { PouchDB } from '../../lib/src/pouchdb/pouchdb-browser.ts';
|
||||
import { reactive, reactiveSource } from 'octagonal-wheels/dataobject/reactive';
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { Notice, requestUrl, type RequestUrlParam, type RequestUrlResponse } from "../../deps.ts";
|
||||
import {
|
||||
type CouchDBCredentials,
|
||||
type EntryDoc,
|
||||
type FilePathWithPrefix,
|
||||
type JWTCredentials,
|
||||
type JWTHeader,
|
||||
type JWTParams,
|
||||
type JWTPayload,
|
||||
type PreparedJWT,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { getPathFromTFile } from "../../common/utils.ts";
|
||||
import {
|
||||
disableEncryption,
|
||||
enableEncryption,
|
||||
isCloudantURI,
|
||||
isValidRemoteCouchDBURI,
|
||||
replicationFilter,
|
||||
} from "../../lib/src/pouchdb/utils_couchdb.ts";
|
||||
import { setNoticeClass } from "../../lib/src/mock_and_interop/wrapper.ts";
|
||||
import { ObsHttpHandler } from "./APILib/ObsHttpHandler.ts";
|
||||
import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.ts";
|
||||
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive.js";
|
||||
import { arrayBufferToBase64Single, writeString } from "../../lib/src/string_and_binary/convert.ts";
|
||||
import { Refiner } from "octagonal-wheels/dataobject/Refiner";
|
||||
|
||||
setNoticeClass(Notice);
|
||||
|
||||
|
||||
async function fetchByAPI(request: RequestUrlParam): Promise<RequestUrlResponse> {
|
||||
const ret = await requestUrl(request);
|
||||
if (ret.status - (ret.status % 100) !== 200) {
|
||||
@@ -27,31 +43,233 @@ async function fetchByAPI(request: RequestUrlParam): Promise<RequestUrlResponse>
|
||||
}
|
||||
|
||||
export class ModuleObsidianAPI extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
_customHandler!: ObsHttpHandler;
|
||||
authHeaderSource = reactiveSource<string>("");
|
||||
authHeader = reactive(() =>
|
||||
this.authHeaderSource.value == "" ? "" : "Basic " + window.btoa(this.authHeaderSource.value));
|
||||
this.authHeaderSource.value == "" ? "" : "Basic " + window.btoa(this.authHeaderSource.value)
|
||||
);
|
||||
|
||||
last_successful_post = false;
|
||||
$$customFetchHandler(): ObsHttpHandler {
|
||||
if (!this._customHandler) this._customHandler = new ObsHttpHandler(undefined, undefined);
|
||||
return this._customHandler;
|
||||
}
|
||||
$$getLastPostFailedBySize(): boolean {
|
||||
return !this.last_successful_post;
|
||||
}
|
||||
|
||||
async $$connectRemoteCouchDB(uri: string, auth: { username: string; password: string }, disableRequestURI: boolean, passphrase: string | false, useDynamicIterationCount: boolean, performSetup: boolean, skipInfo: boolean, compression: boolean): Promise<string | { db: PouchDB.Database<EntryDoc>; info: PouchDB.Core.DatabaseInfo }> {
|
||||
async fetchByAPI(
|
||||
url: string,
|
||||
localURL: string,
|
||||
method: string,
|
||||
authHeader: string,
|
||||
opts?: RequestInit
|
||||
): Promise<Response> {
|
||||
const body = opts?.body as string;
|
||||
|
||||
const transformedHeaders = { ...(opts?.headers as Record<string, string>) };
|
||||
if (authHeader != "") transformedHeaders["authorization"] = authHeader;
|
||||
delete transformedHeaders["host"];
|
||||
delete transformedHeaders["Host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
delete transformedHeaders["Content-Length"];
|
||||
const requestParam: RequestUrlParam = {
|
||||
url,
|
||||
method: opts?.method,
|
||||
body: body,
|
||||
headers: transformedHeaders,
|
||||
contentType: "application/json",
|
||||
// contentType: opts.headers,
|
||||
};
|
||||
const size = body ? ` (${body.length})` : "";
|
||||
try {
|
||||
this.plugin.requestCount.value = this.plugin.requestCount.value + 1;
|
||||
const r = await fetchByAPI(requestParam);
|
||||
if (method == "POST" || method == "PUT") {
|
||||
this.last_successful_post = r.status - (r.status % 100) == 200;
|
||||
} else {
|
||||
this.last_successful_post = true;
|
||||
}
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> ${r.status}`, LOG_LEVEL_DEBUG);
|
||||
|
||||
return new Response(r.arrayBuffer, {
|
||||
headers: r.headers,
|
||||
status: r.status,
|
||||
statusText: `${r.status}`,
|
||||
});
|
||||
} catch (ex) {
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL_VERBOSE);
|
||||
// limit only in bulk_docs.
|
||||
if (url.toString().indexOf("_bulk_docs") !== -1) {
|
||||
this.last_successful_post = false;
|
||||
}
|
||||
this._log(ex);
|
||||
throw ex;
|
||||
} finally {
|
||||
this.plugin.responseCount.value = this.plugin.responseCount.value + 1;
|
||||
}
|
||||
}
|
||||
|
||||
_importKey(auth: JWTCredentials) {
|
||||
if (auth.jwtAlgorithm == "HS256" || auth.jwtAlgorithm == "HS512") {
|
||||
const key = (auth.jwtKey || "").trim();
|
||||
if (key == "") {
|
||||
throw new Error("JWT key is empty");
|
||||
}
|
||||
const binaryDerString = window.atob(key);
|
||||
const binaryDer = new Uint8Array(binaryDerString.length);
|
||||
for (let i = 0; i < binaryDerString.length; i++) {
|
||||
binaryDer[i] = binaryDerString.charCodeAt(i);
|
||||
}
|
||||
const hashName = auth.jwtAlgorithm == "HS256" ? "SHA-256" : "SHA-512";
|
||||
return crypto.subtle.importKey("raw", binaryDer, { name: "HMAC", hash: { name: hashName } }, true, [
|
||||
"sign",
|
||||
]);
|
||||
} else if (auth.jwtAlgorithm == "ES256" || auth.jwtAlgorithm == "ES512") {
|
||||
const pem = auth.jwtKey
|
||||
.replace(/-----BEGIN [^-]+-----/, "")
|
||||
.replace(/-----END [^-]+-----/, "")
|
||||
.replace(/\s+/g, "");
|
||||
// const pem = key.replace(/\s/g, "");
|
||||
const binaryDerString = window.atob(pem);
|
||||
const binaryDer = new Uint8Array(binaryDerString.length);
|
||||
for (let i = 0; i < binaryDerString.length; i++) {
|
||||
binaryDer[i] = binaryDerString.charCodeAt(i);
|
||||
}
|
||||
// const binaryDer = base64ToArrayBuffer(pem);
|
||||
const namedCurve = auth.jwtAlgorithm == "ES256" ? "P-256" : "P-521";
|
||||
const param = { name: "ECDSA", namedCurve };
|
||||
return crypto.subtle.importKey("pkcs8", binaryDer, param, true, ["sign"]);
|
||||
} else {
|
||||
throw new Error("Supplied JWT algorithm is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
_currentCryptoKey = new Refiner<JWTCredentials, CryptoKey>({
|
||||
evaluation: async (auth, previous) => {
|
||||
return await this._importKey(auth);
|
||||
},
|
||||
});
|
||||
|
||||
_jwt = new Refiner<JWTParams, PreparedJWT>({
|
||||
evaluation: async (params, previous) => {
|
||||
const encodedHeader = btoa(JSON.stringify(params.header));
|
||||
const encodedPayload = btoa(JSON.stringify(params.payload));
|
||||
const buff = `${encodedHeader}.${encodedPayload}`.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
|
||||
const key = await this._currentCryptoKey.update(params.credentials).value;
|
||||
let token = "";
|
||||
if (params.header.alg == "ES256" || params.header.alg == "ES512") {
|
||||
const jwt = await crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: { name: "SHA-256" } },
|
||||
key,
|
||||
writeString(buff)
|
||||
);
|
||||
token = (await arrayBufferToBase64Single(jwt))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
} else if (params.header.alg == "HS256" || params.header.alg == "HS512") {
|
||||
const jwt = await crypto.subtle.sign(
|
||||
{ name: "HMAC", hash: { name: params.header.alg } },
|
||||
key,
|
||||
writeString(buff)
|
||||
);
|
||||
token = (await arrayBufferToBase64Single(jwt))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
} else {
|
||||
throw new Error("JWT algorithm is not supported.");
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
token: `${buff}.${token}`,
|
||||
} as PreparedJWT;
|
||||
},
|
||||
});
|
||||
|
||||
_jwtParams = new Refiner<JWTCredentials, JWTParams>({
|
||||
evaluation(source, previous) {
|
||||
const kid = source.jwtKid || undefined;
|
||||
const sub = (source.jwtSub || "").trim();
|
||||
if (sub == "") {
|
||||
throw new Error("JWT sub is empty");
|
||||
}
|
||||
const algorithm = source.jwtAlgorithm || "";
|
||||
if (!algorithm) {
|
||||
throw new Error("JWT algorithm is not configured.");
|
||||
}
|
||||
if (algorithm != "HS256" && algorithm != "HS512" && algorithm != "ES256" && algorithm != "ES512") {
|
||||
throw new Error("JWT algorithm is not supported.");
|
||||
}
|
||||
const header: JWTHeader = {
|
||||
alg: source.jwtAlgorithm || "HS256",
|
||||
typ: "JWT",
|
||||
kid,
|
||||
};
|
||||
const iat = ~~(new Date().getTime() / 1000);
|
||||
const exp = iat + (source.jwtExpDuration || 5) * 60; // 5 minutes
|
||||
const payload = {
|
||||
exp,
|
||||
iat,
|
||||
sub: source.jwtSub || "",
|
||||
"_couchdb.roles": ["_admin"],
|
||||
} satisfies JWTPayload;
|
||||
return {
|
||||
header,
|
||||
payload,
|
||||
credentials: source,
|
||||
};
|
||||
},
|
||||
shouldUpdate(isDifferent, source, previous) {
|
||||
if (isDifferent) {
|
||||
return true;
|
||||
}
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
// if expired.
|
||||
const d = ~~(new Date().getTime() / 1000);
|
||||
if (previous.payload.exp < d) {
|
||||
// console.warn(`jwt expired ${previous.payload.exp} < ${d}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
async $$connectRemoteCouchDB(
|
||||
uri: string,
|
||||
auth: CouchDBCredentials,
|
||||
disableRequestURI: boolean,
|
||||
passphrase: string | false,
|
||||
useDynamicIterationCount: boolean,
|
||||
performSetup: boolean,
|
||||
skipInfo: boolean,
|
||||
compression: boolean,
|
||||
customHeaders: Record<string, string>
|
||||
): Promise<string | { db: PouchDB.Database<EntryDoc>; info: PouchDB.Core.DatabaseInfo }> {
|
||||
if (!isValidRemoteCouchDBURI(uri)) return "Remote URI is not valid";
|
||||
if (uri.toLowerCase() != uri) return "Remote URI and database name could not contain capital letters.";
|
||||
if (uri.indexOf(" ") !== -1) return "Remote URI and database name could not contain spaces.";
|
||||
const userNameAndPassword = (auth.username && auth.password) ? `${auth.username}:${auth.password}` : "";
|
||||
if (this.authHeaderSource.value != userNameAndPassword) {
|
||||
this.authHeaderSource.value = userNameAndPassword;
|
||||
let authHeader = "";
|
||||
if ("username" in auth) {
|
||||
const userNameAndPassword = auth.username && auth.password ? `${auth.username}:${auth.password}` : "";
|
||||
if (this.authHeaderSource.value != userNameAndPassword) {
|
||||
this.authHeaderSource.value = userNameAndPassword;
|
||||
}
|
||||
authHeader = this.authHeader.value;
|
||||
} else if ("jwtAlgorithm" in auth) {
|
||||
const params = await this._jwtParams.update(auth).value;
|
||||
const jwt = await this._jwt.update(params).value;
|
||||
const token = jwt.token;
|
||||
authHeader = `Bearer ${token}`;
|
||||
}
|
||||
const authHeader = this.authHeader.value;
|
||||
// const _this = this;
|
||||
|
||||
const conf: PouchDB.HttpAdapter.HttpAdapterConfiguration = {
|
||||
adapter: "http",
|
||||
auth,
|
||||
auth: "username" in auth ? auth : undefined,
|
||||
skip_setup: !performSetup,
|
||||
fetch: async (url: string | Request, opts?: RequestInit) => {
|
||||
let size = "";
|
||||
@@ -62,99 +280,100 @@ export class ModuleObsidianAPI extends AbstractObsidianModule implements IObsidi
|
||||
if (opts_length > 1000 * 1000 * 10) {
|
||||
// over 10MB
|
||||
if (isCloudantURI(uri)) {
|
||||
this.plugin.last_successful_post = false;
|
||||
this.last_successful_post = false;
|
||||
this._log("This request should fail on IBM Cloudant.", LOG_LEVEL_VERBOSE);
|
||||
throw new Error("This request should fail on IBM Cloudant.");
|
||||
}
|
||||
}
|
||||
size = ` (${opts_length})`;
|
||||
}
|
||||
if (!disableRequestURI && typeof url == "string" && typeof (opts?.body ?? "") == "string") {
|
||||
const body = opts?.body as string;
|
||||
|
||||
const transformedHeaders = { ...(opts?.headers as Record<string, string>) };
|
||||
if (authHeader != "") transformedHeaders["authorization"] = authHeader;
|
||||
delete transformedHeaders["host"];
|
||||
delete transformedHeaders["Host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
delete transformedHeaders["Content-Length"];
|
||||
const requestParam: RequestUrlParam = {
|
||||
url,
|
||||
method: opts?.method,
|
||||
body: body,
|
||||
headers: transformedHeaders,
|
||||
contentType: "application/json",
|
||||
// contentType: opts.headers,
|
||||
};
|
||||
try {
|
||||
if (!disableRequestURI && typeof url == "string" && typeof (opts?.body ?? "") == "string") {
|
||||
return await this.fetchByAPI(url, localURL, method, authHeader, opts);
|
||||
}
|
||||
// --> native Fetch API.
|
||||
|
||||
try {
|
||||
this.plugin.requestCount.value = this.plugin.requestCount.value + 1;
|
||||
const r = await fetchByAPI(requestParam);
|
||||
if (method == "POST" || method == "PUT") {
|
||||
this.plugin.last_successful_post = r.status - (r.status % 100) == 200;
|
||||
} else {
|
||||
this.plugin.last_successful_post = true;
|
||||
}
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> ${r.status}`, LOG_LEVEL_DEBUG);
|
||||
|
||||
return new Response(r.arrayBuffer, {
|
||||
headers: r.headers,
|
||||
status: r.status,
|
||||
statusText: `${r.status}`,
|
||||
});
|
||||
} catch (ex) {
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL_VERBOSE);
|
||||
// limit only in bulk_docs.
|
||||
if (url.toString().indexOf("_bulk_docs") !== -1) {
|
||||
this.plugin.last_successful_post = false;
|
||||
}
|
||||
this._log(ex);
|
||||
throw ex;
|
||||
} finally {
|
||||
this.plugin.responseCount.value = this.plugin.responseCount.value + 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.settings.enableDebugTools) {
|
||||
// Issue #407
|
||||
(opts!.headers as Headers).append("ngrok-skip-browser-warning", "123");
|
||||
}
|
||||
this.plugin.requestCount.value = this.plugin.requestCount.value + 1;
|
||||
const response: Response = await fetch(url, opts);
|
||||
if (method == "POST" || method == "PUT") {
|
||||
this.plugin.last_successful_post = response.ok;
|
||||
} else {
|
||||
this.plugin.last_successful_post = true;
|
||||
}
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> ${response.status}`, LOG_LEVEL_DEBUG);
|
||||
if (Math.floor(response.status / 100) !== 2) {
|
||||
if (method != "GET" && localURL.indexOf("/_local/") === -1 && !localURL.endsWith("/")) {
|
||||
const r = response.clone();
|
||||
this._log(`The request may have failed. The reason sent by the server: ${r.status}: ${r.statusText}`);
|
||||
|
||||
try {
|
||||
this._log(await (await r.blob()).text(), LOG_LEVEL_VERBOSE);
|
||||
} catch (_) {
|
||||
this._log("Cloud not parse response", LOG_LEVEL_VERBOSE);
|
||||
this._log(_, LOG_LEVEL_VERBOSE);
|
||||
if (customHeaders) {
|
||||
for (const [key, value] of Object.entries(customHeaders)) {
|
||||
if (key && value) {
|
||||
(opts!.headers as Headers).append(key, value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._log(`Just checkpoint or some server information has been missing. The 404 error shown above is not an error.`, LOG_LEVEL_VERBOSE)
|
||||
// // Issue #407
|
||||
// (opts!.headers as Headers).append("ngrok-skip-browser-warning", "123");
|
||||
}
|
||||
// debugger;
|
||||
if (!("username" in auth)) {
|
||||
(opts!.headers as Headers).append("authorization", authHeader);
|
||||
}
|
||||
this.plugin.requestCount.value = this.plugin.requestCount.value + 1;
|
||||
const response: Response = await fetch(url, opts);
|
||||
if (method == "POST" || method == "PUT") {
|
||||
this.last_successful_post = response.ok;
|
||||
} else {
|
||||
this.last_successful_post = true;
|
||||
}
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> ${response.status}`, LOG_LEVEL_DEBUG);
|
||||
if (Math.floor(response.status / 100) !== 2) {
|
||||
if (response.status == 404) {
|
||||
if (method === "GET" && localURL.indexOf("/_local/") === -1) {
|
||||
this._log(
|
||||
`Just checkpoint or some server information has been missing. The 404 error shown above is not an error.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const r = response.clone();
|
||||
this._log(
|
||||
`The request may have failed. The reason sent by the server: ${r.status}: ${r.statusText}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
try {
|
||||
const result = await r.text();
|
||||
this._log(result, LOG_LEVEL_VERBOSE);
|
||||
} catch (_) {
|
||||
this._log("Cloud not fetch response body", LOG_LEVEL_VERBOSE);
|
||||
this._log(_, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
}
|
||||
return response;
|
||||
} catch (ex) {
|
||||
if (ex instanceof TypeError) {
|
||||
this._log(
|
||||
"Failed to fetch by native fetch API. Trying to fetch by API to get more information."
|
||||
);
|
||||
const resp2 = await this.fetchByAPI(url.toString(), localURL, method, authHeader, opts);
|
||||
if (resp2.status / 100 == 2) {
|
||||
this._log(
|
||||
"The request was successful by API. But the native fetch API failed! Please check CORS settings on the remote database!. While this condition, you cannot enable LiveSync",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return resp2;
|
||||
}
|
||||
const r2 = resp2.clone();
|
||||
const msg = await r2.text();
|
||||
this._log(`Failed to fetch by API. ${resp2.status}: ${msg}`, LOG_LEVEL_NOTICE);
|
||||
return resp2;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
return response;
|
||||
} catch (ex) {
|
||||
} catch (ex: any) {
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL_VERBOSE);
|
||||
const msg = ex instanceof Error ? `${ex?.name}:${ex?.message}` : ex?.toString();
|
||||
this._log(`Failed to fetch: ${msg}`, LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
// limit only in bulk_docs.
|
||||
if (url.toString().indexOf("_bulk_docs") !== -1) {
|
||||
this.plugin.last_successful_post = false;
|
||||
this.last_successful_post = false;
|
||||
}
|
||||
this._log(ex);
|
||||
throw ex;
|
||||
} finally {
|
||||
this.plugin.responseCount.value = this.plugin.responseCount.value + 1;
|
||||
}
|
||||
|
||||
// return await fetch(url, opts);
|
||||
},
|
||||
};
|
||||
@@ -172,10 +391,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule implements IObsidi
|
||||
const info = await db.info();
|
||||
return { db: db, info: info };
|
||||
} catch (ex: any) {
|
||||
let msg = `${ex?.name}:${ex?.message}`;
|
||||
if (ex?.name == "TypeError" && ex?.message == "Failed to fetch") {
|
||||
msg += "\n**Note** This error caused by many reasons. The only sure thing is you didn't touch the server.\nTo check details, open inspector.";
|
||||
}
|
||||
const msg = `${ex?.name}:${ex?.message}`;
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return msg;
|
||||
}
|
||||
@@ -190,7 +406,10 @@ export class ModuleObsidianAPI extends AbstractObsidianModule implements IObsidi
|
||||
return this.app.vault.getName();
|
||||
}
|
||||
$$getVaultName(): string {
|
||||
return this.core.$$vaultName() + (this.settings?.additionalSuffixOfDatabaseName ? ("-" + this.settings.additionalSuffixOfDatabaseName) : "");
|
||||
return (
|
||||
this.core.$$vaultName() +
|
||||
(this.settings?.additionalSuffixOfDatabaseName ? "-" + this.settings.additionalSuffixOfDatabaseName : "")
|
||||
);
|
||||
}
|
||||
$$getActiveFilePath(): FilePathWithPrefix | undefined {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
@@ -201,7 +420,6 @@ export class ModuleObsidianAPI extends AbstractObsidianModule implements IObsidi
|
||||
}
|
||||
|
||||
$anyGetAppId(): Promise<string | undefined> {
|
||||
return Promise.resolve(`${("appId" in this.app ? this.app.appId : "")}`);
|
||||
return Promise.resolve(`${"appId" in this.app ? this.app.appId : ""}`);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import { AbstractObsidianModule, type IObsidianModule } from '../AbstractObsidianModule.ts';
|
||||
import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from '../../common/events.js';
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from 'octagonal-wheels/common/logger';
|
||||
import { scheduleTask } from 'octagonal-wheels/concurrency/task';
|
||||
import { type TFile } from '../../deps.ts';
|
||||
import { fireAndForget } from 'octagonal-wheels/promises';
|
||||
import { type FilePathWithPrefix } from '../../lib/src/common/types.ts';
|
||||
import { reactive, reactiveSource } from 'octagonal-wheels/dataobject/reactive';
|
||||
import { collectingChunks, pluginScanningCount, hiddenFilesEventCount, hiddenFilesProcessingCount } from '../../lib/src/mock_and_interop/stores.ts';
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from "../../common/events.js";
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { type TFile } from "../../deps.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { type FilePathWithPrefix } from "../../lib/src/common/types.ts";
|
||||
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import {
|
||||
collectingChunks,
|
||||
pluginScanningCount,
|
||||
hiddenFilesEventCount,
|
||||
hiddenFilesProcessingCount,
|
||||
} from "../../lib/src/mock_and_interop/stores.ts";
|
||||
|
||||
export class ModuleObsidianEvents extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
// this.registerEvent(this.app.workspace.on("editor-change", ));
|
||||
this.plugin.registerEvent(this.app.vault.on("rename", (file, oldPath) => {
|
||||
eventHub.emitEvent(EVENT_FILE_RENAMED, { newPath: file.path, old: oldPath });
|
||||
}));
|
||||
this.plugin.registerEvent(this.app.workspace.on("active-leaf-change", () => eventHub.emitEvent(EVENT_LEAF_ACTIVE_CHANGED)));
|
||||
this.plugin.registerEvent(
|
||||
this.app.vault.on("rename", (file, oldPath) => {
|
||||
eventHub.emitEvent(EVENT_FILE_RENAMED, {
|
||||
newPath: file.path as FilePathWithPrefix,
|
||||
old: oldPath as FilePathWithPrefix,
|
||||
});
|
||||
})
|
||||
);
|
||||
this.plugin.registerEvent(
|
||||
this.app.workspace.on("active-leaf-change", () => eventHub.emitEvent(EVENT_LEAF_ACTIVE_CHANGED))
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
|
||||
$$performRestart(): void {
|
||||
this._performAppReload();
|
||||
}
|
||||
@@ -33,22 +43,20 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
|
||||
swapSaveCommand() {
|
||||
this._log("Modifying callback of the save command", LOG_LEVEL_VERBOSE);
|
||||
const saveCommandDefinition = (this.app as any).commands?.commands?.[
|
||||
"editor:save-file"
|
||||
];
|
||||
const saveCommandDefinition = (this.app as any).commands?.commands?.["editor:save-file"];
|
||||
const save = saveCommandDefinition?.callback;
|
||||
if (typeof save === "function") {
|
||||
this.initialCallback = save;
|
||||
saveCommandDefinition.callback = () => {
|
||||
scheduleTask("syncOnEditorSave", 250, () => {
|
||||
if (this.plugin._unloaded) {
|
||||
if (this.core.$$isUnloaded()) {
|
||||
this._log("Unload and remove the handler.", LOG_LEVEL_VERBOSE);
|
||||
saveCommandDefinition.callback = this.initialCallback;
|
||||
this.initialCallback = undefined;
|
||||
} else {
|
||||
if (this.settings.syncOnEditorSave) {
|
||||
this._log("Sync on Editor Save.", LOG_LEVEL_VERBOSE);
|
||||
fireAndForget(() => this.core.$$replicate());
|
||||
fireAndForget(() => this.core.$$replicateByEvent());
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -60,7 +68,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
//@ts-ignore
|
||||
window.CodeMirrorAdapter.commands.save = () => {
|
||||
//@ts-ignore
|
||||
_this.app.commands.executeCommandById('editor:save-file')
|
||||
_this.app.commands.executeCommandById("editor:save-file");
|
||||
// _this.app.performCommand('editor:save-file');
|
||||
};
|
||||
}
|
||||
@@ -98,14 +106,14 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
// TODO:FIXME AT V0.17.31, this logic has been disabled.
|
||||
if (navigator.onLine && this.localDatabase.needScanning) {
|
||||
this.localDatabase.needScanning = false;
|
||||
await this.plugin.$$performFullScan();
|
||||
await this.core.$$performFullScan();
|
||||
}
|
||||
}
|
||||
|
||||
async watchWindowVisibilityAsync() {
|
||||
if (this.settings.suspendFileWatching) return;
|
||||
if (!this.settings.isConfigured) return;
|
||||
if (!this.plugin.isReady) return;
|
||||
if (!this.core.$$isReady()) return;
|
||||
|
||||
if (this.isLastHidden && !this.hasFocus) {
|
||||
// NO OP while non-focused after made hidden;
|
||||
@@ -124,7 +132,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
await this.core.$everyBeforeSuspendProcess();
|
||||
} else {
|
||||
// suspend all temporary.
|
||||
if (this.plugin.suspended) return;
|
||||
if (this.core.$$isSuspended()) return;
|
||||
if (!this.hasFocus) return;
|
||||
await this.core.$everyOnResumeProcess();
|
||||
await this.core.$everyAfterResumeProcess();
|
||||
@@ -133,7 +141,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
watchWorkspaceOpen(file: TFile | null) {
|
||||
if (this.settings.suspendFileWatching) return;
|
||||
if (!this.settings.isConfigured) return;
|
||||
if (!this.plugin.isReady) return;
|
||||
if (!this.core.$$isReady()) return;
|
||||
if (!file) return;
|
||||
scheduleTask("watch-workspace-open", 500, () => fireAndForget(() => this.watchWorkspaceOpenAsync(file)));
|
||||
}
|
||||
@@ -141,13 +149,13 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
async watchWorkspaceOpenAsync(file: TFile) {
|
||||
if (this.settings.suspendFileWatching) return;
|
||||
if (!this.settings.isConfigured) return;
|
||||
if (!this.plugin.isReady) return;
|
||||
if (!this.core.$$isReady()) return;
|
||||
await this.core.$everyCommitPendingFileEvent();
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
if (this.settings.syncOnFileOpen && !this.plugin.suspended) {
|
||||
await this.core.$$replicate();
|
||||
if (this.settings.syncOnFileOpen && !this.core.$$isSuspended()) {
|
||||
await this.core.$$replicateByEvent();
|
||||
}
|
||||
await this.core.$$queueConflictCheckIfOpen(file.path as FilePathWithPrefix);
|
||||
}
|
||||
@@ -158,9 +166,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
|
||||
$$askReload(message?: string) {
|
||||
if (this.core.isReloadingScheduled) {
|
||||
if (this.core.$$isReloadingScheduled()) {
|
||||
this._log(`Reloading is already scheduled`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
@@ -168,16 +175,17 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
const RESTART_NOW = "Yes, restart immediately";
|
||||
const RESTART_AFTER_STABLE = "Yes, schedule a restart after stabilisation";
|
||||
const RETRY_LATER = "No, Leave it to me";
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message || "Do you want to restart and reload Obsidian now?", [
|
||||
RESTART_AFTER_STABLE,
|
||||
RESTART_NOW,
|
||||
RETRY_LATER], { defaultAction: RETRY_LATER });
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message || "Do you want to restart and reload Obsidian now?",
|
||||
[RESTART_AFTER_STABLE, RESTART_NOW, RETRY_LATER],
|
||||
{ defaultAction: RETRY_LATER }
|
||||
);
|
||||
if (ret == RESTART_NOW) {
|
||||
this._performAppReload();
|
||||
} else if (ret == RESTART_AFTER_STABLE) {
|
||||
this.core.$$scheduleAppReload();
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
$$scheduleAppReload() {
|
||||
if (!this.core._totalProcessingCount) {
|
||||
@@ -194,24 +202,39 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
const proc = this.core.processingFileEventCount.value;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const __ = __tick.value;
|
||||
return dbCount + replicationCount + storageApplyingCount + chunkCount + pluginScanCount + hiddenFilesCount + conflictProcessCount + e + proc;
|
||||
})
|
||||
this.plugin.registerInterval(setInterval(() => {
|
||||
__tick.value++;
|
||||
}, 1000) as unknown as number);
|
||||
return (
|
||||
dbCount +
|
||||
replicationCount +
|
||||
storageApplyingCount +
|
||||
chunkCount +
|
||||
pluginScanCount +
|
||||
hiddenFilesCount +
|
||||
conflictProcessCount +
|
||||
e +
|
||||
proc
|
||||
);
|
||||
});
|
||||
this.plugin.registerInterval(
|
||||
setInterval(() => {
|
||||
__tick.value++;
|
||||
}, 1000) as unknown as number
|
||||
);
|
||||
|
||||
let stableCheck = 3;
|
||||
this.core._totalProcessingCount.onChanged(e => {
|
||||
this.core._totalProcessingCount.onChanged((e) => {
|
||||
if (e.value == 0) {
|
||||
if (stableCheck-- <= 0) {
|
||||
this._performAppReload();
|
||||
}
|
||||
this._log(`Obsidian will be restarted soon! (Within ${stableCheck} seconds)`, LOG_LEVEL_NOTICE, "restart-notice");
|
||||
this._log(
|
||||
`Obsidian will be restarted soon! (Within ${stableCheck} seconds)`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"restart-notice"
|
||||
);
|
||||
} else {
|
||||
stableCheck = 3;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { addIcon, type Editor, type MarkdownFileInfo, type MarkdownView } from "../../deps.ts";
|
||||
import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "../../lib/src/common/types.ts";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
|
||||
export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
|
||||
// UI
|
||||
addIcon(
|
||||
"replicate",
|
||||
@@ -18,23 +17,22 @@ export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsid
|
||||
</g>`
|
||||
);
|
||||
|
||||
this.addRibbonIcon("replicate", "Replicate", async () => {
|
||||
await this.plugin.$$replicate(true);
|
||||
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await this.core.$$replicate(true);
|
||||
}).addClass("livesync-ribbon-replicate");
|
||||
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-replicate",
|
||||
name: "Replicate now",
|
||||
callback: async () => {
|
||||
await this.plugin.$$replicate();
|
||||
await this.core.$$replicate();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: "Dump information of this doc ",
|
||||
callback: () => {
|
||||
const file = this.plugin.$$getActiveFilePath();
|
||||
const file = this.core.$$getActiveFilePath();
|
||||
if (!file) return;
|
||||
fireAndForget(() => this.localDatabase.getDBEntry(file, {}, true, false));
|
||||
},
|
||||
@@ -45,7 +43,7 @@ export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsid
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void this.plugin.$$queueConflictCheckIfOpen(file.path as FilePathWithPrefix);
|
||||
void this.core.$$queueConflictCheckIfOpen(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,23 +58,23 @@ export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsid
|
||||
this.settings.liveSync = true;
|
||||
this._log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.plugin.realizeSettingSyncMode();
|
||||
await this.plugin.saveSettings();
|
||||
await this.core.$$realizeSettingSyncMode();
|
||||
await this.core.$$saveSettingData();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-suspendall",
|
||||
name: "Toggle All Sync.",
|
||||
callback: async () => {
|
||||
if (this.plugin.suspended) {
|
||||
this.plugin.suspended = false;
|
||||
if (this.core.$$isSuspended()) {
|
||||
this.core.$$setSuspended(false);
|
||||
this._log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this.plugin.suspended = true;
|
||||
this.core.$$setSuspended(true);
|
||||
this._log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.plugin.realizeSettingSyncMode();
|
||||
await this.plugin.saveSettings();
|
||||
await this.core.$$realizeSettingSyncMode();
|
||||
await this.core.$$saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,9 +82,9 @@ export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsid
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
callback: async () => {
|
||||
await this.plugin.$$performFullScan(true)
|
||||
}
|
||||
})
|
||||
await this.core.$$performFullScan(true);
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-runbatch",
|
||||
@@ -94,22 +92,20 @@ export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsid
|
||||
callback: async () => {
|
||||
await this.core.$everyCommitPendingFileEvent();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// TODO, Replicator is possibly one of features. It should be moved to features.
|
||||
this.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
callback: () => {
|
||||
this.plugin.replicator.terminateSync();
|
||||
this.core.replicator.terminateSync();
|
||||
},
|
||||
})
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
|
||||
}
|
||||
$everyOnload(): Promise<boolean> {
|
||||
this.app.workspace.onLayoutReady(this.plugin.onLiveSyncReady.bind(this.plugin));
|
||||
// eslint-disable-next-line no-unused-labels
|
||||
this.app.workspace.onLayoutReady(this.core.$$onLiveSyncReady.bind(this.core));
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -124,13 +120,10 @@ export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsid
|
||||
await leaves[0].setViewState({
|
||||
type: viewType,
|
||||
active: true,
|
||||
})
|
||||
});
|
||||
}
|
||||
if (leaves.length > 0) {
|
||||
this.app.workspace.revealLeaf(
|
||||
leaves[0]
|
||||
);
|
||||
await this.app.workspace.revealLeaf(leaves[0]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
12
src/modules/extraFeaturesObsidian/ModuleExtraSyncObsidian.ts
Normal file
12
src/modules/extraFeaturesObsidian/ModuleExtraSyncObsidian.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
|
||||
export class ModuleExtraSyncObsidian extends AbstractObsidianModule implements IObsidianModule {
|
||||
deviceAndVaultName: string = "";
|
||||
|
||||
$$getDeviceAndVaultName(): string {
|
||||
return this.deviceAndVaultName;
|
||||
}
|
||||
$$setDeviceAndVaultName(name: string): void {
|
||||
this.deviceAndVaultName = name;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||
import { __onMissingTranslation } from "../../lib/src/common/i18n";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
@@ -6,90 +6,134 @@ import { eventHub } from "../../common/events";
|
||||
import { enableTestFunction } from "./devUtil/testUtils.ts";
|
||||
import { TestPaneView, VIEW_TYPE_TEST } from "./devUtil/TestPaneView.ts";
|
||||
import { writable } from "svelte/store";
|
||||
import type { FilePathWithPrefix } from "../../lib/src/common/types.ts";
|
||||
|
||||
export class ModuleDev extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
__onMissingTranslation(() => { });
|
||||
__onMissingTranslation(() => {});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async onMissingTranslation(key: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const filename = `missing-translation-`;
|
||||
const time = now.toISOString().split("T")[0];
|
||||
const outFile = `${filename}${time}.jsonl`;
|
||||
const piece = JSON.stringify({
|
||||
[key]: {},
|
||||
});
|
||||
const writePiece = piece.substring(1, piece.length - 1) + ",";
|
||||
try {
|
||||
await this.core.storageAccess.ensureDir(this.app.vault.configDir + "/ls-debug/");
|
||||
await this.core.storageAccess.appendHiddenFile(
|
||||
this.app.vault.configDir + "/ls-debug/" + outFile,
|
||||
writePiece + "\n"
|
||||
);
|
||||
} catch (ex) {
|
||||
this._log(`Could not write ${outFile}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(`Missing translation: ${writePiece}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
$everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
// eslint-disable-next-line no-unused-labels
|
||||
this.onMissingTranslation = this.onMissingTranslation.bind(this);
|
||||
__onMissingTranslation((key) => {
|
||||
const now = new Date();
|
||||
const filename = `missing-translation-`
|
||||
const time = now.toISOString().split("T")[0];
|
||||
const outFile = `${filename}${time}.jsonl`;
|
||||
const piece = JSON.stringify(
|
||||
{
|
||||
[key]: {}
|
||||
}
|
||||
)
|
||||
const writePiece = piece.substring(1, piece.length - 1) + ",";
|
||||
fireAndForget(async () => {
|
||||
try {
|
||||
await this.core.storageAccess.ensureDir(this.app.vault.configDir + "/ls-debug/");
|
||||
await this.core.storageAccess.appendHiddenFile(this.app.vault.configDir + "/ls-debug/" + outFile, writePiece + "\n")
|
||||
} catch (ex) {
|
||||
this._log(`Could not write ${outFile}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(`Missing translation: ${writePiece}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
});
|
||||
})
|
||||
void this.onMissingTranslation(key);
|
||||
});
|
||||
type STUB = {
|
||||
toc: Set<string>,
|
||||
stub: { [key: string]: { [key: string]: Map<string, Record<string, string>> } }
|
||||
toc: Set<string>;
|
||||
stub: { [key: string]: { [key: string]: Map<string, Record<string, string>> } };
|
||||
};
|
||||
eventHub.onEvent("document-stub-created", (e: CustomEvent<STUB>) => {
|
||||
eventHub.onEvent("document-stub-created", (detail: STUB) => {
|
||||
fireAndForget(async () => {
|
||||
const stub = e.detail.stub;
|
||||
const toc = e.detail.toc;
|
||||
const stub = detail.stub;
|
||||
const toc = detail.toc;
|
||||
|
||||
const stubDocX =
|
||||
Object.entries(stub).map(([key, value]) => {
|
||||
return [`## ${key}`, Object.entries(value).
|
||||
map(([key2, value2]) => {
|
||||
return [`### ${key2}`,
|
||||
([...(value2.entries())].map(([key3, value3]) => {
|
||||
// return `#### ${key3}` + "\n" + JSON.stringify(value3);
|
||||
const isObsolete = value3["is_obsolete"] ? " (obsolete)" : "";
|
||||
const desc = value3["desc"] ?? "";
|
||||
const key = value3["key"] ? "Setting key: " + value3["key"] + "\n" : "";
|
||||
return `#### ${key3}${isObsolete}\n${key}${desc}\n`
|
||||
}))].flat()
|
||||
}).flat()].flat()
|
||||
}).flat();
|
||||
const stubDocMD = `
|
||||
const stubDocX = Object.entries(stub)
|
||||
.map(([key, value]) => {
|
||||
return [
|
||||
`## ${key}`,
|
||||
Object.entries(value)
|
||||
.map(([key2, value2]) => {
|
||||
return [
|
||||
`### ${key2}`,
|
||||
[...value2.entries()].map(([key3, value3]) => {
|
||||
// return `#### ${key3}` + "\n" + JSON.stringify(value3);
|
||||
const isObsolete = value3["is_obsolete"] ? " (obsolete)" : "";
|
||||
const desc = value3["desc"] ?? "";
|
||||
const key = value3["key"] ? "Setting key: " + value3["key"] + "\n" : "";
|
||||
return `#### ${key3}${isObsolete}\n${key}${desc}\n`;
|
||||
}),
|
||||
].flat();
|
||||
})
|
||||
.flat(),
|
||||
].flat();
|
||||
})
|
||||
.flat();
|
||||
const stubDocMD =
|
||||
`
|
||||
| Icon | Description |
|
||||
| :---: | ----------------------------------------------------------------- |
|
||||
` +
|
||||
[...toc.values()].map(e => `${e}`).join("\n") + "\n\n" +
|
||||
[...toc.values()].map((e) => `${e}`).join("\n") +
|
||||
"\n\n" +
|
||||
stubDocX.join("\n");
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.app.vault.configDir + "/ls-debug/stub-doc.md", stubDocMD);
|
||||
})
|
||||
await this.core.storageAccess.writeHiddenFileAuto(
|
||||
this.app.vault.configDir + "/ls-debug/stub-doc.md",
|
||||
stubDocMD
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
enableTestFunction(this.plugin);
|
||||
this.registerView(
|
||||
VIEW_TYPE_TEST,
|
||||
(leaf) => new TestPaneView(leaf, this.plugin, this)
|
||||
);
|
||||
this.registerView(VIEW_TYPE_TEST, (leaf) => new TestPaneView(leaf, this.plugin, this));
|
||||
this.addCommand({
|
||||
id: "view-test",
|
||||
name: "Open Test dialogue",
|
||||
callback: () => {
|
||||
void this.core.$$showView(VIEW_TYPE_TEST);
|
||||
}
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async $everyOnLayoutReady(): Promise<boolean> {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
if (await this.core.storageAccess.isExistsIncludeHidden("_SHOWDIALOGAUTO.md")) {
|
||||
void this.core.$$showView(VIEW_TYPE_TEST);
|
||||
}
|
||||
// if (await this.core.storageAccess.isExistsIncludeHidden("_SHOWDIALOGAUTO.md")) {
|
||||
// void this.core.$$showView(VIEW_TYPE_TEST);
|
||||
// }
|
||||
|
||||
this.addCommand({
|
||||
id: "test-create-conflict",
|
||||
name: "Create conflict",
|
||||
callback: async () => {
|
||||
const filename = "test-create-conflict.md";
|
||||
const content = `# Test create conflict\n\n`;
|
||||
const w = await this.core.databaseFileAccess.store({
|
||||
name: filename as FilePathWithPrefix,
|
||||
path: filename as FilePathWithPrefix,
|
||||
body: new Blob([content], { type: "text/markdown" }),
|
||||
stat: {
|
||||
ctime: new Date().getTime(),
|
||||
mtime: new Date().getTime(),
|
||||
size: content.length,
|
||||
type: "file",
|
||||
},
|
||||
});
|
||||
if (w) {
|
||||
const id = await this.core.$$path2id(filename as FilePathWithPrefix);
|
||||
const f = await this.core.localDatabase.getRaw(id);
|
||||
console.log(f);
|
||||
console.log(f._rev);
|
||||
const revConflict = f._rev.split("-")[0] + "-" + (parseInt(f._rev.split("-")[1]) + 1).toString();
|
||||
console.log(await this.core.localDatabase.bulkDocsRaw([f], { new_edits: false }));
|
||||
console.log(
|
||||
await this.core.localDatabase.bulkDocsRaw([{ ...f, _rev: revConflict }], { new_edits: false })
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
await delay(1);
|
||||
return true;
|
||||
}
|
||||
testResults = writable<[boolean, string, string][]>([]);
|
||||
@@ -111,4 +155,4 @@ export class ModuleDev extends AbstractObsidianModule implements IObsidianModule
|
||||
// this.addTestResult("Test of test3", true);
|
||||
return this.testDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import { shareRunningResult } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule";
|
||||
|
||||
export class ModuleIntegratedTest extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
async waitFor(proc: () => Promise<boolean>, timeout = 10000): Promise<boolean> {
|
||||
await delay(100);
|
||||
const start = Date.now();
|
||||
while (!await proc()) {
|
||||
while (!(await proc())) {
|
||||
if (timeout > 0) {
|
||||
if (Date.now() - start > timeout) {
|
||||
this._log(`Timeout`);
|
||||
@@ -40,25 +39,27 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
}
|
||||
}
|
||||
async assert(proc: () => Promise<boolean>): Promise<boolean> {
|
||||
if (!await proc()) {
|
||||
if (!(await proc())) {
|
||||
this._log(`Assertion failed`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async _orDie(key: string, proc: () => Promise<boolean>): Promise<true> | never {
|
||||
if (!await this._test(key, proc)) {
|
||||
if (!(await this._test(key, proc))) {
|
||||
throw new Error(`${key}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
tryReplicate() {
|
||||
if (!this.settings.liveSync) {
|
||||
return shareRunningResult("replicate-test", async () => { await this.core.$$replicate() });
|
||||
return shareRunningResult("replicate-test", async () => {
|
||||
await this.core.$$replicate();
|
||||
});
|
||||
}
|
||||
}
|
||||
async readStorageContent(file: FilePathWithPrefix): Promise<string | undefined> {
|
||||
if (!await this.core.storageAccess.isExistsIncludeHidden(file)) {
|
||||
if (!(await this.core.storageAccess.isExistsIncludeHidden(file))) {
|
||||
return undefined;
|
||||
}
|
||||
return await this.core.storageAccess.readHiddenFileText(file);
|
||||
@@ -70,13 +71,14 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
await this.core.$anyResolveConflictByNewest(stepFile);
|
||||
await this.core.storageAccess.writeFileAuto(stepFile, stepContent);
|
||||
await this._orDie(`Wait for acknowledge ${no}`, async () => {
|
||||
if (!await this.waitWithReplicating(
|
||||
async () => {
|
||||
return await this.storageContentIsEqual(stepAckFile, stepContent)
|
||||
}, 20000)
|
||||
) return false;
|
||||
if (
|
||||
!(await this.waitWithReplicating(async () => {
|
||||
return await this.storageContentIsEqual(stepAckFile, stepContent);
|
||||
}, 20000))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
})
|
||||
});
|
||||
return true;
|
||||
}
|
||||
async _join(no: number, title: string): Promise<boolean> {
|
||||
@@ -86,14 +88,14 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
const stepContent = `Step ${no}`;
|
||||
|
||||
await this._orDie(`Wait for step ${no} (${title})`, async () => {
|
||||
if (!await this.waitWithReplicating(
|
||||
async () => {
|
||||
return await this.storageContentIsEqual(stepFile, stepContent)
|
||||
}, 20000)
|
||||
) return false;
|
||||
if (
|
||||
!(await this.waitWithReplicating(async () => {
|
||||
return await this.storageContentIsEqual(stepFile, stepContent);
|
||||
}, 20000))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
)
|
||||
});
|
||||
await this.core.$anyResolveConflictByNewest(stepAckFile);
|
||||
await this.core.storageAccess.writeFileAuto(stepAckFile, stepContent);
|
||||
await this.tryReplicate();
|
||||
@@ -105,13 +107,13 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
title,
|
||||
isGameChanger,
|
||||
proc,
|
||||
check
|
||||
check,
|
||||
}: {
|
||||
step: number,
|
||||
title: string,
|
||||
isGameChanger: boolean,
|
||||
proc: () => Promise<any>,
|
||||
check: () => Promise<boolean>,
|
||||
step: number;
|
||||
title: string;
|
||||
isGameChanger: boolean;
|
||||
proc: () => Promise<any>;
|
||||
check: () => Promise<boolean>;
|
||||
}): Promise<boolean> {
|
||||
if (isGameChanger) {
|
||||
await this._proceed(step, title);
|
||||
@@ -121,9 +123,7 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
this._log(`Error: ${e}`);
|
||||
return false;
|
||||
}
|
||||
return await this._orDie(`Step ${step} - ${title}`,
|
||||
async () => await this.waitWithReplicating(check)
|
||||
);
|
||||
return await this._orDie(`Step ${step} - ${title}`, async () => await this.waitWithReplicating(check));
|
||||
} else {
|
||||
return await this._join(step, title);
|
||||
}
|
||||
@@ -135,14 +135,21 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
// async testReceiver(testMain: (testFileName: FilePathWithPrefix) => Promise<boolean>): Promise<boolean> {
|
||||
|
||||
// }
|
||||
async nonLiveTestRunner(isLeader: boolean, testMain: (testFileName: FilePathWithPrefix, isLeader: boolean) => Promise<boolean>): Promise<boolean> {
|
||||
async nonLiveTestRunner(
|
||||
isLeader: boolean,
|
||||
testMain: (testFileName: FilePathWithPrefix, isLeader: boolean) => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
const storage = this.core.storageAccess;
|
||||
// const database = this.core.databaseFileAccess;
|
||||
// const _orDie = this._orDie.bind(this);
|
||||
const testCommandFile = "IT.md" as FilePathWithPrefix;
|
||||
const textCommandResponseFile = "ITx.md" as FilePathWithPrefix;
|
||||
let testFileName: FilePathWithPrefix;
|
||||
this.addTestResult("-------Starting ... ", true, `Test as ${isLeader ? "Leader" : "Receiver"} command file ${testCommandFile}`);
|
||||
this.addTestResult(
|
||||
"-------Starting ... ",
|
||||
true,
|
||||
`Test as ${isLeader ? "Leader" : "Receiver"} command file ${testCommandFile}`
|
||||
);
|
||||
if (isLeader) {
|
||||
await this._proceed(0, "start");
|
||||
}
|
||||
@@ -154,14 +161,14 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => await storage.removeHidden(testCommandFile),
|
||||
check: async () => !(await storage.isExistsIncludeHidden(testCommandFile)),
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 1,
|
||||
title: "Make sure that command File Not Exists On Receiver",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => await storage.removeHidden(textCommandResponseFile),
|
||||
check: async () => !(await storage.isExistsIncludeHidden(textCommandResponseFile)),
|
||||
})
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 2,
|
||||
@@ -173,14 +180,14 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
await storage.writeFileAuto(testCommandFile, testFileName);
|
||||
},
|
||||
check: () => Promise.resolve(true),
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 3,
|
||||
title: "Wait for the command file to be arrived",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => await storage.isExistsIncludeHidden(testCommandFile),
|
||||
})
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 4,
|
||||
@@ -190,34 +197,31 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
await storage.writeHiddenFileAuto(textCommandResponseFile, "!");
|
||||
},
|
||||
check: () => Promise.resolve(true),
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 5,
|
||||
title: "Wait for the response file to be arrived",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => await storage.isExistsIncludeHidden(textCommandResponseFile),
|
||||
})
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 6,
|
||||
title: "Proceed to begin the test",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => {
|
||||
|
||||
},
|
||||
proc: async () => {},
|
||||
check: () => Promise.resolve(true),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 6,
|
||||
title: "Begin the test",
|
||||
isGameChanger: !false,
|
||||
proc: async () => {
|
||||
},
|
||||
proc: async () => {},
|
||||
check: () => {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
})
|
||||
});
|
||||
// await this.step(0, isLeader, true);
|
||||
try {
|
||||
this.addTestResult("** Main------", true, ``);
|
||||
@@ -234,15 +238,18 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
}
|
||||
|
||||
return true;
|
||||
// Make sure the
|
||||
// Make sure the
|
||||
}
|
||||
|
||||
|
||||
async testBasic(filename: FilePathWithPrefix, isLeader: boolean): Promise<boolean> {
|
||||
const storage = this.core.storageAccess;
|
||||
const database = this.core.databaseFileAccess;
|
||||
|
||||
await this.addTestResult(`---**Starting Basic Test**---`, true, `Test as ${isLeader ? "Leader" : "Receiver"} command file ${filename}`);
|
||||
await this.addTestResult(
|
||||
`---**Starting Basic Test**---`,
|
||||
true,
|
||||
`Test as ${isLeader ? "Leader" : "Receiver"} command file ${filename}`
|
||||
);
|
||||
// if (isLeader) {
|
||||
// await this._proceed(0);
|
||||
// }
|
||||
@@ -252,10 +259,9 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
step: 0,
|
||||
title: "Make sure that file is not exist",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => !(await storage.isExists(filename)),
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 1,
|
||||
@@ -263,47 +269,47 @@ export class ModuleIntegratedTest extends AbstractObsidianModule implements IObs
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => await storage.writeFileAuto(filename, "Hello World"),
|
||||
check: async () => await storage.isExists(filename),
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 2,
|
||||
title: "Make sure the file is arrived",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => await storage.isExists(filename),
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 3,
|
||||
title: "Update to Hello World 2",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => await storage.writeFileAuto(filename, "Hello World 2"),
|
||||
check: async () => await this.storageContentIsEqual(filename, "Hello World 2"),
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 4,
|
||||
title: "Make sure the modified file is arrived",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, "Hello World 2"),
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 5,
|
||||
title: "Update to Hello World 3",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => await storage.writeFileAuto(filename, "Hello World 3"),
|
||||
check: async () => await this.storageContentIsEqual(filename, "Hello World 3"),
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 6,
|
||||
title: "Make sure the modified file is arrived",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, "Hello World 3"),
|
||||
})
|
||||
});
|
||||
|
||||
const multiLineContent = `Line1:A
|
||||
Line2:B
|
||||
Line3:C
|
||||
Line4:D`
|
||||
Line4:D`;
|
||||
|
||||
await this.performStep({
|
||||
step: 7,
|
||||
@@ -311,38 +317,35 @@ Line4:D`
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => await storage.writeFileAuto(filename, multiLineContent),
|
||||
check: async () => await this.storageContentIsEqual(filename, multiLineContent),
|
||||
})
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 8,
|
||||
title: "Make sure the modified file is arrived",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, multiLineContent),
|
||||
})
|
||||
});
|
||||
|
||||
// While LiveSync, possibly cannot cause the conflict.
|
||||
if (!this.settings.liveSync) {
|
||||
|
||||
|
||||
|
||||
// Step 9 Make Conflict But Resolvable
|
||||
const multiLineContentL = `Line1:A
|
||||
Line2:B
|
||||
Line3:C!
|
||||
Line4:D`
|
||||
Line4:D`;
|
||||
const multiLineContentC = `Line1:A
|
||||
Line2:bbbbb
|
||||
Line3:C
|
||||
Line4:D`
|
||||
Line4:D`;
|
||||
|
||||
await this.performStep({
|
||||
step: 9,
|
||||
title: "Progress to be conflicted",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: () => Promise.resolve(true),
|
||||
})
|
||||
});
|
||||
|
||||
await storage.writeFileAuto(filename, isLeader ? multiLineContentL : multiLineContentC);
|
||||
|
||||
@@ -350,62 +353,62 @@ Line4:D`
|
||||
step: 10,
|
||||
title: "Update As Conflicted",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: () => Promise.resolve(true),
|
||||
})
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 10,
|
||||
title: "Make sure Automatically resolved",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => (await database.getConflictedRevs(filename)).length === 0,
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 11,
|
||||
title: "Make sure Automatically resolved",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => (await database.getConflictedRevs(filename)).length === 0,
|
||||
})
|
||||
|
||||
|
||||
});
|
||||
|
||||
const sensiblyMergedContent = `Line1:A
|
||||
Line2:bbbbb
|
||||
Line3:C!
|
||||
Line4:D`
|
||||
Line4:D`;
|
||||
|
||||
await this.performStep({
|
||||
step: 12,
|
||||
title: "Make sure Sensibly Merged on Leader",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, sensiblyMergedContent),
|
||||
})
|
||||
});
|
||||
await this.performStep({
|
||||
step: 13,
|
||||
title: "Make sure Sensibly Merged on Receiver",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => { },
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, sensiblyMergedContent),
|
||||
})
|
||||
});
|
||||
}
|
||||
await this.performStep({
|
||||
step: 14,
|
||||
title: "Delete File",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => { await storage.removeHidden(filename) },
|
||||
check: async () => !await storage.isExists(filename),
|
||||
})
|
||||
proc: async () => {
|
||||
await storage.removeHidden(filename);
|
||||
},
|
||||
check: async () => !(await storage.isExists(filename)),
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 15,
|
||||
title: "Make sure File is deleted",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => { },
|
||||
check: async () => !await storage.isExists(filename),
|
||||
})
|
||||
proc: async () => {},
|
||||
check: async () => !(await storage.isExists(filename)),
|
||||
});
|
||||
this._log(`The Basic Test has been completed`, LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
@@ -429,13 +432,12 @@ Line4:D`
|
||||
this._log(`Starting Test`);
|
||||
await this.testBasicEvent(isLeader);
|
||||
if (this.settings.remoteType == REMOTE_MINIO) await this.testBasicLive(isLeader);
|
||||
|
||||
} catch (e) {
|
||||
this._log(e)
|
||||
this._log(e);
|
||||
this._log(`Error: ${e}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,19 @@ import { delay } from "octagonal-wheels/promises";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { eventHub } from "../../common/events";
|
||||
import { webcrypto } from "crypto";
|
||||
import { getWebCrypto } from "../../lib/src/mods.ts";
|
||||
import { uint8ArrayToHexString } from "octagonal-wheels/binary/hex";
|
||||
import { parseYaml, requestUrl, stringifyYaml } from "obsidian";
|
||||
import type { FilePath } from "../../lib/src/common/types.ts";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
"debug-sync-status": string[];
|
||||
}
|
||||
}
|
||||
|
||||
export class ModuleReplicateTest extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
|
||||
testRootPath = "_test/";
|
||||
testInfoPath = "_testinfo/";
|
||||
|
||||
@@ -18,7 +22,6 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
return this.core.$$getVaultName().indexOf("dev") >= 0 && this.core.$$vaultName().indexOf("recv") < 0;
|
||||
}
|
||||
|
||||
|
||||
get nameByKind() {
|
||||
if (!this.isLeader) {
|
||||
return "RECV";
|
||||
@@ -45,7 +48,6 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async dumpList() {
|
||||
if (this.settings.syncInternalFiles) {
|
||||
this._log("Write file list (Include Hidden)");
|
||||
@@ -69,39 +71,38 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
void this._dumpFileList("files.md").finally(() => {
|
||||
void this.refreshSyncStatus();
|
||||
});
|
||||
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "dump-file-structure-ih",
|
||||
name: "Dump Structure (Include Hidden)",
|
||||
callback: () => {
|
||||
const d = "files.md";
|
||||
void this._dumpFileListIncludeHidden(d);
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "dump-file-structure-auto",
|
||||
name: "Dump Structure",
|
||||
callback: () => {
|
||||
void this.dumpList();
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "dump-file-test",
|
||||
name: `Perform Test (Dev) ${this.isLeader ? "(Leader)" : "(Recv)"}`,
|
||||
callback: () => {
|
||||
void this.performTestManually();
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "watch-sync-result",
|
||||
name: `Watch sync result is matched between devices`,
|
||||
callback: () => {
|
||||
this.watchIsSynchronised = !this.watchIsSynchronised;
|
||||
void this.refreshSyncStatus();
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
this.app.vault.on("modify", async (file) => {
|
||||
if (file.path.startsWith(this.testInfoPath)) {
|
||||
await this.refreshSyncStatus();
|
||||
@@ -111,7 +112,7 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
return true;
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
this.statusBarSyncStatus = this.plugin.addStatusBarItem();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
@@ -158,12 +159,16 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async _dumpFileList(outFile?: string) {
|
||||
if (!this.core || !this.core.storageAccess) {
|
||||
this._log("No storage access", LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
const files = this.core.storageAccess.getFiles();
|
||||
const out = [] as any[];
|
||||
const webcrypto = await getWebCrypto();
|
||||
for (const file of files) {
|
||||
if (!await this.core.$$isTargetFile(file.path)) {
|
||||
if (!(await this.core.$$isTargetFile(file.path))) {
|
||||
continue;
|
||||
}
|
||||
if (file.path.startsWith(this.testInfoPath)) continue;
|
||||
@@ -177,8 +182,8 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
name: file.name,
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
hash: hashStr
|
||||
}
|
||||
hash: hashStr,
|
||||
};
|
||||
// const fileLine = `-${file.path}:${stat.size}:${stat.mtime}:${hashStr}`;
|
||||
out.push(item);
|
||||
}
|
||||
@@ -197,10 +202,13 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
async _dumpFileListIncludeHidden(outFile?: string) {
|
||||
const ignorePatterns = this.settings.syncInternalFilesIgnorePatterns
|
||||
.replace(/\n| /g, "")
|
||||
.split(",").filter(e => e).map(e => new RegExp(e, "i"));
|
||||
.split(",")
|
||||
.filter((e) => e)
|
||||
.map((e) => new RegExp(e, "i"));
|
||||
const out = [] as any[];
|
||||
const files = await this.core.storageAccess.getFilesIncludeHidden("", undefined, ignorePatterns);
|
||||
console.dir(files);
|
||||
// console.dir(files);
|
||||
const webcrypto = await getWebCrypto();
|
||||
for (const file of files) {
|
||||
// if (!await this.core.$$isTargetFile(file)) {
|
||||
// continue;
|
||||
@@ -216,8 +224,8 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
name: file.split("/").pop(),
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
hash: hashStr
|
||||
}
|
||||
hash: hashStr,
|
||||
};
|
||||
// const fileLine = `-${file.path}:${stat.size}:${stat.mtime}:${hashStr}`;
|
||||
out.push(item);
|
||||
}
|
||||
@@ -257,27 +265,27 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
"docs/tech_info.md",
|
||||
"docs/terms.md",
|
||||
"docs/troubleshooting.md",
|
||||
'images/1.png',
|
||||
'images/2.png',
|
||||
'images/corrupted_data.png',
|
||||
'images/hatch.png',
|
||||
'images/lock_pattern1.png',
|
||||
'images/lock_pattern2.png',
|
||||
'images/quick_setup_1.png',
|
||||
'images/quick_setup_2.png',
|
||||
'images/quick_setup_3.png',
|
||||
'images/quick_setup_3b.png',
|
||||
'images/quick_setup_4.png',
|
||||
'images/quick_setup_5.png',
|
||||
'images/quick_setup_6.png',
|
||||
'images/quick_setup_7.png',
|
||||
'images/quick_setup_8.png',
|
||||
'images/quick_setup_9_1.png',
|
||||
'images/quick_setup_9_2.png',
|
||||
'images/quick_setup_10.png',
|
||||
'images/remote_db_setting.png',
|
||||
'images/write_logs_into_the_file.png',
|
||||
]
|
||||
"images/1.png",
|
||||
"images/2.png",
|
||||
"images/corrupted_data.png",
|
||||
"images/hatch.png",
|
||||
"images/lock_pattern1.png",
|
||||
"images/lock_pattern2.png",
|
||||
"images/quick_setup_1.png",
|
||||
"images/quick_setup_2.png",
|
||||
"images/quick_setup_3.png",
|
||||
"images/quick_setup_3b.png",
|
||||
"images/quick_setup_4.png",
|
||||
"images/quick_setup_5.png",
|
||||
"images/quick_setup_6.png",
|
||||
"images/quick_setup_7.png",
|
||||
"images/quick_setup_8.png",
|
||||
"images/quick_setup_9_1.png",
|
||||
"images/quick_setup_9_2.png",
|
||||
"images/quick_setup_10.png",
|
||||
"images/remote_db_setting.png",
|
||||
"images/write_logs_into_the_file.png",
|
||||
];
|
||||
for (const file of files) {
|
||||
const remote = remoteTopDir + file;
|
||||
const local = this.testRootPath + file;
|
||||
@@ -297,7 +305,7 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
async waitFor(proc: () => Promise<boolean>, timeout = 10000): Promise<boolean> {
|
||||
await delay(100);
|
||||
const start = Date.now();
|
||||
while (!await proc()) {
|
||||
while (!(await proc())) {
|
||||
if (timeout > 0) {
|
||||
if (Date.now() - start > timeout) {
|
||||
this._log(`Timeout`);
|
||||
@@ -310,7 +318,6 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
}
|
||||
|
||||
async testConflictedManually1() {
|
||||
|
||||
await this.core.$$replicate();
|
||||
|
||||
const commonFile = `Resolve!
|
||||
@@ -322,18 +329,20 @@ export class ModuleReplicateTest extends AbstractObsidianModule implements IObsi
|
||||
|
||||
await this.core.$$replicate();
|
||||
await this.core.$$replicate();
|
||||
if (await this.core.confirm.askYesNoDialog("Ready to begin the test conflict Manually 1?", { timeout: 30, defaultOption: "Yes" }) == "no") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to begin the test conflict Manually 1?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const fileA = `Resolve to KEEP THIS
|
||||
Willy Wonka, Willy Wonka, the amazing chocolatier!!`
|
||||
Willy Wonka, Willy Wonka, the amazing chocolatier!!`;
|
||||
|
||||
const fileB = `Resolve to DISCARD THIS
|
||||
Charlie Bucket, Charlie Bucket, the amazing chocolatier!!`
|
||||
|
||||
|
||||
Charlie Bucket, Charlie Bucket, the amazing chocolatier!!`;
|
||||
|
||||
if (this.isLeader) {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "wonka.md", fileA);
|
||||
@@ -341,25 +350,37 @@ Charlie Bucket, Charlie Bucket, the amazing chocolatier!!`
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "wonka.md", fileB);
|
||||
}
|
||||
|
||||
if (await this.core.confirm.askYesNoDialog("Ready to check the result of Manually 1?", { timeout: 30, defaultOption: "Yes" }) == "no") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to check the result of Manually 1?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.core.$$replicate();
|
||||
await this.core.$$replicate();
|
||||
|
||||
|
||||
if (!await this.waitFor(async () => {
|
||||
await this.core.$$replicate();
|
||||
return await this.__assertStorageContent(this.testRootPath + "wonka.md" as FilePath, fileA, false, true) == true;
|
||||
}, 30000)) {
|
||||
return await this.__assertStorageContent(this.testRootPath + "wonka.md" as FilePath, fileA, false, true);
|
||||
if (
|
||||
!(await this.waitFor(async () => {
|
||||
await this.core.$$replicate();
|
||||
return (
|
||||
(await this.__assertStorageContent(
|
||||
(this.testRootPath + "wonka.md") as FilePath,
|
||||
fileA,
|
||||
false,
|
||||
true
|
||||
)) == true
|
||||
);
|
||||
}, 30000))
|
||||
) {
|
||||
return await this.__assertStorageContent((this.testRootPath + "wonka.md") as FilePath, fileA, false, true);
|
||||
}
|
||||
return true;
|
||||
// We have to check the result
|
||||
}
|
||||
|
||||
async testConflictedManually2() {
|
||||
|
||||
await this.core.$$replicate();
|
||||
|
||||
const commonFile = `Resolve To concatenate
|
||||
@@ -371,56 +392,82 @@ ABCDEFG`;
|
||||
|
||||
await this.core.$$replicate();
|
||||
await this.core.$$replicate();
|
||||
if (await this.core.confirm.askYesNoDialog("Ready to begin the test conflict Manually 2?", { timeout: 30, defaultOption: "Yes" }) == "no") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to begin the test conflict Manually 2?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileA = `Resolve to Concatenate
|
||||
ABCDEFGHIJKLMNOPQRSTYZ`
|
||||
ABCDEFGHIJKLMNOPQRSTYZ`;
|
||||
|
||||
const fileB = `Resolve to Concatenate
|
||||
AJKLMNOPQRSTUVWXYZ`
|
||||
AJKLMNOPQRSTUVWXYZ`;
|
||||
|
||||
const concatenated = `Resolve to Concatenate
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ`
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ`;
|
||||
if (this.isLeader) {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "concat.md", fileA);
|
||||
} else {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "concat.md", fileB);
|
||||
}
|
||||
if (await this.core.confirm.askYesNoDialog("Ready to test conflict Manually 2?", { timeout: 30, defaultOption: "Yes" }) == "no") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to test conflict Manually 2?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.core.$$replicate();
|
||||
await this.core.$$replicate();
|
||||
|
||||
|
||||
if (!await this.waitFor(async () => {
|
||||
await this.core.$$replicate();
|
||||
return await this.__assertStorageContent(this.testRootPath + "concat.md" as FilePath, concatenated, false, true) == true;
|
||||
}, 30000)) {
|
||||
return await this.__assertStorageContent(this.testRootPath + "concat.md" as FilePath, concatenated, false, true);
|
||||
if (
|
||||
!(await this.waitFor(async () => {
|
||||
await this.core.$$replicate();
|
||||
return (
|
||||
(await this.__assertStorageContent(
|
||||
(this.testRootPath + "concat.md") as FilePath,
|
||||
concatenated,
|
||||
false,
|
||||
true
|
||||
)) == true
|
||||
);
|
||||
}, 30000))
|
||||
) {
|
||||
return await this.__assertStorageContent(
|
||||
(this.testRootPath + "concat.md") as FilePath,
|
||||
concatenated,
|
||||
false,
|
||||
true
|
||||
);
|
||||
}
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
async testConflictAutomatic() {
|
||||
|
||||
if (this.isLeader) {
|
||||
const baseDoc = `Tasks!
|
||||
- [ ] Task 1
|
||||
- [ ] Task 2
|
||||
- [ ] Task 3
|
||||
- [ ] Task 4
|
||||
`
|
||||
`;
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "task.md", baseDoc);
|
||||
}
|
||||
await delay(100)
|
||||
await delay(100);
|
||||
await this.core.$$replicate();
|
||||
await this.core.$$replicate();
|
||||
|
||||
if (await this.core.confirm.askYesNoDialog("Ready to test conflict?", { timeout: 30, defaultOption: "Yes" }) == "no") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to test conflict?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const mod1Doc = `Tasks!
|
||||
@@ -428,14 +475,14 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZ`
|
||||
- [v] Task 2
|
||||
- [ ] Task 3
|
||||
- [ ] Task 4
|
||||
`
|
||||
`;
|
||||
|
||||
const mod2Doc = `Tasks!
|
||||
- [ ] Task 1
|
||||
- [ ] Task 2
|
||||
- [v] Task 3
|
||||
- [ ] Task 4
|
||||
`
|
||||
`;
|
||||
if (this.isLeader) {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "task.md", mod1Doc);
|
||||
} else {
|
||||
@@ -445,7 +492,10 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZ`
|
||||
await this.core.$$replicate();
|
||||
await this.core.$$replicate();
|
||||
await delay(1000);
|
||||
if (await this.core.confirm.askYesNoDialog("Ready to check result?", { timeout: 30, defaultOption: "Yes" }) == "no") {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to check result?", { timeout: 30, defaultOption: "Yes" })) ==
|
||||
"no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.core.$$replicate();
|
||||
@@ -455,8 +505,8 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZ`
|
||||
- [v] Task 2
|
||||
- [v] Task 3
|
||||
- [ ] Task 4
|
||||
`
|
||||
return this.__assertStorageContent(this.testRootPath + "task.md" as FilePath, mergedDoc, false, true);
|
||||
`;
|
||||
return this.__assertStorageContent((this.testRootPath + "task.md") as FilePath, mergedDoc, false, true);
|
||||
}
|
||||
|
||||
async checkConflictResolution() {
|
||||
@@ -465,24 +515,27 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZ`
|
||||
await this.core.rebuilder.resolveAllConflictedFilesByNewerOnes();
|
||||
await this.core.$$replicate();
|
||||
await delay(1000);
|
||||
if (!await this.testConflictAutomatic()) {
|
||||
if (!(await this.testConflictAutomatic())) {
|
||||
this._log("Conflict resolution (Auto) failed", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!await this.testConflictedManually1()) {
|
||||
if (!(await this.testConflictedManually1())) {
|
||||
this._log("Conflict resolution (Manual1) failed", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!await this.testConflictedManually2()) {
|
||||
if (!(await this.testConflictedManually2())) {
|
||||
this._log("Conflict resolution (Manual2) failed", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
async __assertStorageContent(fileName: FilePath, content: string, inverted = false, showResult = false): Promise<boolean | string> {
|
||||
async __assertStorageContent(
|
||||
fileName: FilePath,
|
||||
content: string,
|
||||
inverted = false,
|
||||
showResult = false
|
||||
): Promise<boolean | string> {
|
||||
try {
|
||||
const fileContent = await this.core.storageAccess.readHiddenFileText(fileName);
|
||||
let result = fileContent === content;
|
||||
@@ -525,4 +578,4 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZ`
|
||||
await this._test("Conflict resolution", async () => await this.checkConflictResolution());
|
||||
return this.testDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
isReady = true;
|
||||
// performTest();
|
||||
|
||||
eventHub.once(EVENT_LAYOUT_READY, async () => {
|
||||
eventHub.onceEvent(EVENT_LAYOUT_READY, async () => {
|
||||
if (await plugin.storageAccess.isExistsIncludeHidden("_AUTO_TEST.md")) {
|
||||
new Notice("Auto test file found, running tests...");
|
||||
fireAndForget(async () => {
|
||||
@@ -83,7 +83,7 @@
|
||||
$: resultLines = $results;
|
||||
|
||||
let syncStatus = [] as string[];
|
||||
eventHub.on<string[]>("debug-sync-status", (status) => {
|
||||
eventHub.onEvent("debug-sync-status", (status) => {
|
||||
syncStatus = [...status];
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import {
|
||||
ItemView,
|
||||
WorkspaceLeaf
|
||||
} from "obsidian";
|
||||
import TestPaneComponent from "./TestPane.svelte"
|
||||
import type ObsidianLiveSyncPlugin from "../../../main.ts"
|
||||
import { ItemView, WorkspaceLeaf } from "obsidian";
|
||||
import TestPaneComponent from "./TestPane.svelte";
|
||||
import type ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import type { ModuleDev } from "../ModuleDev.ts";
|
||||
export const VIEW_TYPE_TEST = "ols-pane-test";
|
||||
//Log view
|
||||
export class TestPaneView extends ItemView {
|
||||
|
||||
component?: TestPaneComponent;
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
moduleDev: ModuleDev;
|
||||
icon = "view-log";
|
||||
title: string = "Self-hosted LiveSync Test and Results"
|
||||
title: string = "Self-hosted LiveSync Test and Results";
|
||||
navigation = true;
|
||||
|
||||
getIcon(): string {
|
||||
@@ -26,7 +22,6 @@ export class TestPaneView extends ItemView {
|
||||
this.moduleDev = moduleDev;
|
||||
}
|
||||
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_TEST;
|
||||
}
|
||||
@@ -35,19 +30,17 @@ export class TestPaneView extends ItemView {
|
||||
return "Self-hosted LiveSync Test and Results";
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
async onOpen() {
|
||||
this.component = new TestPaneComponent({
|
||||
target: this.contentEl,
|
||||
props: {
|
||||
plugin: this.plugin,
|
||||
moduleDev: this.moduleDev
|
||||
moduleDev: this.moduleDev,
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
async onClose() {
|
||||
this.component?.$destroy();
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -7,40 +7,44 @@ export function enableTestFunction(plugin_: ObsidianLiveSyncPlugin) {
|
||||
plugin = plugin_;
|
||||
}
|
||||
export function addDebugFileLog(message: any, stackLog = false) {
|
||||
fireAndForget(serialized("debug-log", async () => {
|
||||
const now = new Date();
|
||||
const filename = `debug-log`
|
||||
const time = now.toISOString().split("T")[0];
|
||||
const outFile = `${filename}${time}.jsonl`;
|
||||
// const messageContent = typeof message == "string" ? message : message instanceof Error ? `${message.name}:${message.message}` : JSON.stringify(message, null, 2);
|
||||
const timestamp = now.toLocaleString();
|
||||
const timestampEpoch = now;
|
||||
let out = { "timestamp": timestamp, epoch: timestampEpoch, } as Record<string, any>;
|
||||
if (message instanceof Error) {
|
||||
// debugger;
|
||||
// console.dir(message.stack);
|
||||
out = { ...out, message };
|
||||
} else if (stackLog) {
|
||||
if (stackLog) {
|
||||
const stackE = new Error();
|
||||
const stack = stackE.stack;
|
||||
out = { ...out, stack }
|
||||
fireAndForget(
|
||||
serialized("debug-log", async () => {
|
||||
const now = new Date();
|
||||
const filename = `debug-log`;
|
||||
const time = now.toISOString().split("T")[0];
|
||||
const outFile = `${filename}${time}.jsonl`;
|
||||
// const messageContent = typeof message == "string" ? message : message instanceof Error ? `${message.name}:${message.message}` : JSON.stringify(message, null, 2);
|
||||
const timestamp = now.toLocaleString();
|
||||
const timestampEpoch = now;
|
||||
let out = { timestamp: timestamp, epoch: timestampEpoch } as Record<string, any>;
|
||||
if (message instanceof Error) {
|
||||
// debugger;
|
||||
// console.dir(message.stack);
|
||||
out = { ...out, message };
|
||||
} else if (stackLog) {
|
||||
if (stackLog) {
|
||||
const stackE = new Error();
|
||||
const stack = stackE.stack;
|
||||
out = { ...out, stack };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof message == "object") {
|
||||
out = { ...out, ...message, }
|
||||
} else {
|
||||
out = {
|
||||
result: message
|
||||
if (typeof message == "object") {
|
||||
out = { ...out, ...message };
|
||||
} else {
|
||||
out = {
|
||||
result: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
// const out = "--" + timestamp + "--\n" + messageContent + " " + (stack || "");
|
||||
// const out
|
||||
try {
|
||||
await plugin.storageAccess.appendHiddenFile(plugin.app.vault.configDir + "/ls-debug/" + outFile, JSON.stringify(out) + "\n")
|
||||
} catch {
|
||||
|
||||
//NO OP
|
||||
}
|
||||
}));
|
||||
}
|
||||
// const out = "--" + timestamp + "--\n" + messageContent + " " + (stack || "");
|
||||
// const out
|
||||
try {
|
||||
await plugin.storageAccess.appendHiddenFile(
|
||||
plugin.app.vault.configDir + "/ls-debug/" + outFile,
|
||||
JSON.stringify(out) + "\n"
|
||||
);
|
||||
} catch {
|
||||
//NO OP
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const measures = new Map<string, MeasureResult>();
|
||||
function clearResult(name: string) {
|
||||
measures.set(name, [0, 0]);
|
||||
}
|
||||
async function measureEach(name: string, proc: () => (void | Promise<void>)) {
|
||||
async function measureEach(name: string, proc: () => void | Promise<void>) {
|
||||
const [times, spent] = measures.get(name) ?? [0, 0];
|
||||
|
||||
const start = performance.now();
|
||||
@@ -15,57 +15,76 @@ async function measureEach(name: string, proc: () => (void | Promise<void>)) {
|
||||
if (result instanceof Promise) await result;
|
||||
const end = performance.now();
|
||||
measures.set(name, [times + 1, spent + (end - start)]);
|
||||
|
||||
}
|
||||
function formatNumber(num: number) {
|
||||
return num.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
return num.toLocaleString("en-US", { maximumFractionDigits: 2 });
|
||||
}
|
||||
async function measure(name: string, proc: () => (void | Promise<void>), times: number = 10000, duration: number = 1000): Promise<NamedMeasureResult> {
|
||||
async function measure(
|
||||
name: string,
|
||||
proc: () => void | Promise<void>,
|
||||
times: number = 10000,
|
||||
duration: number = 1000
|
||||
): Promise<NamedMeasureResult> {
|
||||
const from = Date.now();
|
||||
let last = times;
|
||||
clearResult(name);
|
||||
do {
|
||||
await measureEach(name, proc);
|
||||
} while (last-- > 0 && (Date.now() - from) < duration)
|
||||
} while (last-- > 0 && Date.now() - from < duration);
|
||||
return [name, measures.get(name) as MeasureResult];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-await, @typescript-eslint/require-await
|
||||
async function formatPerfResults(items: NamedMeasureResult[]) {
|
||||
return `| Name | Runs | Each | Total |\n| --- | --- | --- | --- | \n` + items.map(e => `| ${e[0]} | ${e[1][0]} | ${e[1][0] != 0 ? formatNumber(e[1][1] / e[1][0]) : "-"} | ${formatNumber(e[1][0])} |`).join("\n");
|
||||
|
||||
return (
|
||||
`| Name | Runs | Each | Total |\n| --- | --- | --- | --- | \n` +
|
||||
items
|
||||
.map(
|
||||
(e) =>
|
||||
`| ${e[0]} | ${e[1][0]} | ${e[1][0] != 0 ? formatNumber(e[1][1] / e[1][0]) : "-"} | ${formatNumber(e[1][0])} |`
|
||||
)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
export async function perf_trench(plugin: ObsidianLiveSyncPlugin) {
|
||||
clearResult("trench");
|
||||
const trench = new Trench(plugin.simpleStore);
|
||||
const result = [] as NamedMeasureResult[];
|
||||
result.push(await measure("trench-short-string", async () => {
|
||||
const p = trench.evacuate("string");
|
||||
await p();
|
||||
}));
|
||||
result.push(
|
||||
await measure("trench-short-string", async () => {
|
||||
const p = trench.evacuate("string");
|
||||
await p();
|
||||
})
|
||||
);
|
||||
{
|
||||
const testBinary = await plugin.storageAccess.readHiddenFileBinary("testdata/10kb.png");
|
||||
const uint8Array = new Uint8Array(testBinary);
|
||||
result.push(await measure("trench-binary-10kb", async () => {
|
||||
const p = trench.evacuate(uint8Array);
|
||||
await p();
|
||||
}));
|
||||
result.push(
|
||||
await measure("trench-binary-10kb", async () => {
|
||||
const p = trench.evacuate(uint8Array);
|
||||
await p();
|
||||
})
|
||||
);
|
||||
}
|
||||
{
|
||||
const testBinary = await plugin.storageAccess.readHiddenFileBinary("testdata/100kb.jpeg");
|
||||
const uint8Array = new Uint8Array(testBinary);
|
||||
result.push(await measure("trench-binary-100kb", async () => {
|
||||
const p = trench.evacuate(uint8Array);
|
||||
await p();
|
||||
}));
|
||||
result.push(
|
||||
await measure("trench-binary-100kb", async () => {
|
||||
const p = trench.evacuate(uint8Array);
|
||||
await p();
|
||||
})
|
||||
);
|
||||
}
|
||||
{
|
||||
const testBinary = await plugin.storageAccess.readHiddenFileBinary("testdata/1mb.png");
|
||||
const uint8Array = new Uint8Array(testBinary);
|
||||
result.push(await measure("trench-binary-1mb", async () => {
|
||||
const p = trench.evacuate(uint8Array);
|
||||
await p();
|
||||
}));
|
||||
result.push(
|
||||
await measure("trench-binary-1mb", async () => {
|
||||
const p = trench.evacuate(uint8Array);
|
||||
await p();
|
||||
})
|
||||
);
|
||||
}
|
||||
return formatPerfResults(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_pat
|
||||
import { getPathFromTFile, isValidPath } from "../../../common/utils.ts";
|
||||
import { decodeBinary, escapeStringToHTML, readString } from "../../../lib/src/string_and_binary/convert.ts";
|
||||
import ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import { type DocumentID, type FilePathWithPrefix, type LoadedEntry, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../../../lib/src/common/types.ts";
|
||||
import {
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type LoadedEntry,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import { Logger } from "../../../lib/src/common/logger.ts";
|
||||
import { isErrorOfMissingDoc } from "../../../lib/src/pouchdb/utils_couchdb.ts";
|
||||
import { fireAndForget, getDocData, readContent } from "../../../lib/src/common/utils.ts";
|
||||
@@ -19,7 +26,7 @@ function isComparableText(path: string) {
|
||||
}
|
||||
function isComparableTextDecode(path: string) {
|
||||
const ext = path.split(".").splice(-1)[0].toLowerCase();
|
||||
return ["json"].includes(ext)
|
||||
return ["json"].includes(ext);
|
||||
}
|
||||
function readDocument(w: LoadedEntry) {
|
||||
if (w.data.length == 0) return "";
|
||||
@@ -54,10 +61,16 @@ export class DocumentHistoryModal extends Modal {
|
||||
currentDeleted = false;
|
||||
initialRev?: string;
|
||||
|
||||
constructor(app: App, plugin: ObsidianLiveSyncPlugin, file: TFile | FilePathWithPrefix, id?: DocumentID, revision?: string) {
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: ObsidianLiveSyncPlugin,
|
||||
file: TFile | FilePathWithPrefix,
|
||||
id?: DocumentID,
|
||||
revision?: string
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.file = (file instanceof TFile) ? getPathFromTFile(file) : file;
|
||||
this.file = file instanceof TFile ? getPathFromTFile(file) : file;
|
||||
this.id = id;
|
||||
this.initialRev = revision;
|
||||
if (!file && id) {
|
||||
@@ -85,9 +98,9 @@ export class DocumentHistoryModal extends Modal {
|
||||
this.range.max = "0";
|
||||
this.range.value = "";
|
||||
this.range.disabled = true;
|
||||
this.contentView.setText(`History of this file was not recorded.`);
|
||||
this.contentView.setText(`We don't have any history for this note.`);
|
||||
} else {
|
||||
this.contentView.setText(`Error occurred.`);
|
||||
this.contentView.setText(`Error while loading file.`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
@@ -95,7 +108,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
async loadRevs(initialRev?: string) {
|
||||
if (this.revs_info.length == 0) return;
|
||||
if (initialRev) {
|
||||
const rIndex = this.revs_info.findIndex(e => e.rev == initialRev);
|
||||
const rIndex = this.revs_info.findIndex((e) => e.rev == initialRev);
|
||||
if (rIndex >= 0) {
|
||||
this.range.value = `${this.revs_info.length - 1 - rIndex}`;
|
||||
}
|
||||
@@ -163,8 +176,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
} else if (isImage(this.file)) {
|
||||
const src = this.generateBlobURL("base", w1data);
|
||||
const overlay = this.generateBlobURL("overlay", readDocument(w2) as Uint8Array);
|
||||
result =
|
||||
`<div class='ls-imgdiff-wrap'>
|
||||
result = `<div class='ls-imgdiff-wrap'>
|
||||
<div class='overlay'>
|
||||
<img class='img-base' src="${src}">
|
||||
<img class='img-overlay' src='${overlay}'>
|
||||
@@ -174,14 +186,12 @@ export class DocumentHistoryModal extends Modal {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if (result == undefined) {
|
||||
if (typeof w1data != "string") {
|
||||
if (isImage(this.file)) {
|
||||
const src = this.generateBlobURL("base", w1data);
|
||||
result =
|
||||
`<div class='ls-imgdiff-wrap'>
|
||||
result = `<div class='ls-imgdiff-wrap'>
|
||||
<div class='overlay'>
|
||||
<img class='img-base' src="${src}">
|
||||
</div>
|
||||
@@ -193,7 +203,8 @@ export class DocumentHistoryModal extends Modal {
|
||||
}
|
||||
}
|
||||
if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file";
|
||||
this.contentView.innerHTML = (this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
||||
this.contentView.innerHTML =
|
||||
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,9 +268,9 @@ export class DocumentHistoryModal extends Modal {
|
||||
const leaf = this.plugin.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(targetFile);
|
||||
} else {
|
||||
Logger("The file could not view on the editor", LOG_LEVEL_NOTICE)
|
||||
Logger("Unable to display the file in the editor", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
};
|
||||
buttons.createEl("button", { text: "Back to this revision" }, (e) => {
|
||||
e.addClass("mod-cta");
|
||||
e.addEventListener("click", () => {
|
||||
@@ -285,9 +296,9 @@ export class DocumentHistoryModal extends Modal {
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.BlobURLs.forEach(value => {
|
||||
this.BlobURLs.forEach((value) => {
|
||||
console.log(value);
|
||||
if (value) URL.revokeObjectURL(value);
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { diff_match_patch } from "../../../deps.ts";
|
||||
import { DocumentHistoryModal } from "../DocumentHistory/DocumentHistoryModal.ts";
|
||||
import { isPlainText, stripAllPrefixes } from "../../../lib/src/string_and_binary/path.ts";
|
||||
import { TFile } from "../../../deps.ts";
|
||||
import { getPath } from "../../../common/utils.ts";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
|
||||
@@ -67,7 +66,10 @@
|
||||
|
||||
for (const revInfo of reversedRevs) {
|
||||
if (revInfo.status == "available") {
|
||||
const doc = (!isPlain && showDiffInfo) || (checkStorageDiff && revInfo.rev == docA._rev) ? await db.getDBEntry(path, { rev: revInfo.rev }, false, false, true) : await db.getDBEntryMeta(path, { rev: revInfo.rev }, true);
|
||||
const doc =
|
||||
(!isPlain && showDiffInfo) || (checkStorageDiff && revInfo.rev == docA._rev)
|
||||
? await db.getDBEntry(path, { rev: revInfo.rev }, false, false, true)
|
||||
: await db.getDBEntryMeta(path, { rev: revInfo.rev }, true);
|
||||
if (doc === false) continue;
|
||||
const rev = revInfo.rev;
|
||||
|
||||
@@ -95,7 +97,10 @@
|
||||
[DIFF_EQUAL]: 0,
|
||||
[DIFF_INSERT]: 0,
|
||||
} as { [key: number]: number };
|
||||
const px = diff.reduce((p, c) => ({ ...p, [c[0]]: (p[c[0]] ?? 0) + c[1].length }), pxInit);
|
||||
const px = diff.reduce(
|
||||
(p, c) => ({ ...p, [c[0]]: (p[c[0]] ?? 0) + c[1].length }),
|
||||
pxInit
|
||||
);
|
||||
diffDetail = `-${px[DIFF_DELETE]}, +${px[DIFF_INSERT]}`;
|
||||
}
|
||||
}
|
||||
@@ -105,9 +110,13 @@
|
||||
}
|
||||
if (rev == docA._rev) {
|
||||
if (checkStorageDiff) {
|
||||
const isExist = await plugin.storageAccess.isExists(stripAllPrefixes(getPath(docA)));
|
||||
const isExist = await plugin.storageAccess.isExistsIncludeHidden(
|
||||
stripAllPrefixes(getPath(docA))
|
||||
);
|
||||
if (isExist) {
|
||||
const data = await plugin.storageAccess.readFileAuto(stripAllPrefixes(getPath(docA)));
|
||||
const data = await plugin.storageAccess.readHiddenFileBinary(
|
||||
stripAllPrefixes(getPath(docA))
|
||||
);
|
||||
const d = readAsBlob(doc);
|
||||
const result = await isDocContentSame(data, d);
|
||||
if (result) {
|
||||
@@ -188,19 +197,28 @@
|
||||
<div class="globalhistory">
|
||||
<h1>Vault history</h1>
|
||||
<div class="control">
|
||||
<div class="row"><label for="">From:</label><input type="date" bind:value={dispDateFrom} disabled={loading} /></div>
|
||||
<div class="row">
|
||||
<label for="">From:</label><input type="date" bind:value={dispDateFrom} disabled={loading} />
|
||||
</div>
|
||||
<div class="row"><label for="">To:</label><input type="date" bind:value={dispDateTo} disabled={loading} /></div>
|
||||
<div class="row">
|
||||
<label for="">Info:</label>
|
||||
<label><input type="checkbox" bind:checked={showDiffInfo} disabled={loading} /><span>Diff</span></label>
|
||||
<label><input type="checkbox" bind:checked={showChunkCorrected} disabled={loading} /><span>Chunks</span></label>
|
||||
<label><input type="checkbox" bind:checked={checkStorageDiff} disabled={loading} /><span>File integrity</span></label>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={showChunkCorrected} disabled={loading} /><span>Chunks</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={checkStorageDiff} disabled={loading} /><span>File integrity</span
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="">Gathering information...</div>
|
||||
{/if}
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th> Date </th>
|
||||
<th> Path </th>
|
||||
@@ -213,7 +231,7 @@
|
||||
<tr>
|
||||
<td colspan="5" class="more">
|
||||
{#if loading}
|
||||
<div class="" />
|
||||
<div class=""></div>
|
||||
{:else}
|
||||
<div><button on:click={() => nextWeek()}>+1 week</button></div>
|
||||
{/if}
|
||||
@@ -258,12 +276,13 @@
|
||||
<tr>
|
||||
<td colspan="5" class="more">
|
||||
{#if loading}
|
||||
<div class="" />
|
||||
<div class=""></div>
|
||||
{:else}
|
||||
<div><button on:click={() => prevWeek()}>+1 week</button></div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import {
|
||||
ItemView,
|
||||
WorkspaceLeaf
|
||||
} from "../../../deps.ts";
|
||||
import { WorkspaceLeaf } from "../../../deps.ts";
|
||||
import GlobalHistoryComponent from "./GlobalHistory.svelte";
|
||||
import type ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import { SvelteItemView } from "../../../common/SvelteItemView.ts";
|
||||
import { mount } from "svelte";
|
||||
|
||||
export const VIEW_TYPE_GLOBAL_HISTORY = "global-history";
|
||||
export class GlobalHistoryView extends ItemView {
|
||||
export class GlobalHistoryView extends SvelteItemView {
|
||||
instantiateComponent(target: HTMLElement) {
|
||||
return mount(GlobalHistoryComponent, {
|
||||
target: target,
|
||||
props: {
|
||||
plugin: this.plugin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
component?: GlobalHistoryComponent;
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon = "clock";
|
||||
title: string = "";
|
||||
@@ -23,7 +29,6 @@ export class GlobalHistoryView extends ItemView {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_GLOBAL_HISTORY;
|
||||
}
|
||||
@@ -31,19 +36,4 @@ export class GlobalHistoryView extends ItemView {
|
||||
getDisplayText() {
|
||||
return "Vault history";
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
this.component = new GlobalHistoryComponent({
|
||||
target: this.contentEl,
|
||||
props: {
|
||||
plugin: this.plugin,
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.component?.$destroy();
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { App, Modal } from "../../../deps.ts";
|
||||
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
||||
import { CANCELLED, LEAVE_TO_SUBSEQUENT, RESULT_TIMED_OUT, type diff_result } from "../../../lib/src/common/types.ts";
|
||||
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "../../../lib/src/common/types.ts";
|
||||
import { escapeStringToHTML } from "../../../lib/src/string_and_binary/convert.ts";
|
||||
import { delay, fireAndForget, sendValue, waitForValue } from "../../../lib/src/common/utils.ts";
|
||||
import { delay } from "../../../lib/src/common/utils.ts";
|
||||
import { eventHub } from "../../../common/events.ts";
|
||||
import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts";
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
declare global {
|
||||
interface Slips extends LSSlips {
|
||||
"conflict-resolved": typeof CANCELLED | MergeDialogResult;
|
||||
}
|
||||
}
|
||||
|
||||
export type MergeDialogResult = typeof LEAVE_TO_SUBSEQUENT | typeof CANCELLED | string;
|
||||
export class ConflictResolveModal extends Modal {
|
||||
result: diff_result;
|
||||
filename: string;
|
||||
@@ -18,6 +27,7 @@ export class ConflictResolveModal extends Modal {
|
||||
pluginPickMode: boolean = false;
|
||||
localName: string = "Keep A";
|
||||
remoteName: string = "Keep B";
|
||||
offEvent?: ReturnType<typeof eventHub.onEvent>;
|
||||
|
||||
constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string) {
|
||||
super(app);
|
||||
@@ -27,28 +37,26 @@ export class ConflictResolveModal extends Modal {
|
||||
if (this.pluginPickMode) {
|
||||
this.title = "Pick a version";
|
||||
this.remoteName = `Use ${remoteName || "Remote"}`;
|
||||
this.localName = "Use Local"
|
||||
this.localName = "Use Local";
|
||||
}
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
// if not there, simply be ignored.
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
sendValue("cancel-resolve-conflict:" + this.filename, true);
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
// if not there, simply be ignored.
|
||||
sendValue("cancel-resolve-conflict:" + this.filename, true);
|
||||
setTimeout(() => {
|
||||
fireAndForget(async () => {
|
||||
const forceClose = await waitForValue("cancel-resolve-conflict:" + this.filename);
|
||||
// debugger;
|
||||
if (forceClose) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
}, 10)
|
||||
globalSlipBoard.submit("conflict-resolved", this.filename, CANCELLED);
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
this.offEvent = eventHub.onEvent("conflict-cancelled", (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
@@ -60,28 +68,47 @@ export class ConflictResolveModal extends Modal {
|
||||
const x1 = v[0];
|
||||
const x2 = v[1];
|
||||
if (x1 == DIFF_DELETE) {
|
||||
diff += "<span class='deleted'>" + escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") + "</span>";
|
||||
diff +=
|
||||
"<span class='deleted'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
} else if (x1 == DIFF_EQUAL) {
|
||||
diff += "<span class='normal'>" + escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") + "</span>";
|
||||
diff +=
|
||||
"<span class='normal'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
} else if (x1 == DIFF_INSERT) {
|
||||
diff += "<span class='added'>" + escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") + "</span>";
|
||||
diff +=
|
||||
"<span class='added'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
}
|
||||
}
|
||||
|
||||
diff = diff.replace(/\n/g, "<br>");
|
||||
div.innerHTML = diff;
|
||||
const div2 = contentEl.createDiv("");
|
||||
const date1 = new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
|
||||
const date2 = new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
|
||||
const date1 =
|
||||
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
|
||||
const date2 =
|
||||
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
|
||||
div2.innerHTML = `
|
||||
<span class='deleted'>A:${date1}</span><br /><span class='added'>B:${date2}</span><br>
|
||||
`;
|
||||
contentEl.createEl("button", { text: this.localName }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.right.rev))).style.marginRight = "4px";
|
||||
contentEl.createEl("button", { text: this.remoteName }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.left.rev))).style.marginRight = "4px";
|
||||
contentEl.createEl("button", { text: this.localName }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
|
||||
).style.marginRight = "4px";
|
||||
contentEl.createEl("button", { text: this.remoteName }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev))
|
||||
).style.marginRight = "4px";
|
||||
if (!this.pluginPickMode) {
|
||||
contentEl.createEl("button", { text: "Concat both" }, (e) => e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT))).style.marginRight = "4px";
|
||||
contentEl.createEl("button", { text: "Concat both" }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT))
|
||||
).style.marginRight = "4px";
|
||||
}
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => e.addEventListener("click", () => this.sendResponse(CANCELLED))).style.marginRight = "4px";
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED))
|
||||
).style.marginRight = "4px";
|
||||
}
|
||||
|
||||
sendResponse(result: MergeDialogResult) {
|
||||
@@ -92,18 +119,19 @@ export class ConflictResolveModal extends Modal {
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
if (this.consumed) {
|
||||
return;
|
||||
}
|
||||
this.consumed = true;
|
||||
sendValue("close-resolve-conflict:" + this.filename, this.response);
|
||||
sendValue("cancel-resolve-conflict:" + this.filename, false);
|
||||
globalSlipBoard.submit("conflict-resolved", this.filename, this.response);
|
||||
}
|
||||
|
||||
async waitForResult(): Promise<MergeDialogResult> {
|
||||
await delay(100);
|
||||
const r = await waitForValue<MergeDialogResult>("close-resolve-conflict:" + this.filename);
|
||||
if (r === RESULT_TIMED_OUT) return CANCELLED;
|
||||
const r = await globalSlipBoard.awaitNext("conflict-resolved", this.filename);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { logMessages } from "../../../lib/src/mock_and_interop/stores";
|
||||
import type { ReactiveInstance } from "../../../lib/src/dataobject/reactive";
|
||||
import { Logger } from "../../../lib/src/common/logger";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { logMessages } from "../../../lib/src/mock_and_interop/stores";
|
||||
import { reactive, type ReactiveInstance } from "../../../lib/src/dataobject/reactive";
|
||||
import { Logger } from "../../../lib/src/common/logger";
|
||||
import { $msg as msg, currentLang as lang } from "../../../lib/src/common/i18n.ts";
|
||||
|
||||
let unsubscribe: () => void;
|
||||
let messages = [] as string[];
|
||||
let wrapRight = false;
|
||||
let autoScroll = true;
|
||||
let suspended = false;
|
||||
function updateLog(logs: ReactiveInstance<string[]>) {
|
||||
const e = logs.value;
|
||||
if (!suspended) {
|
||||
messages = [...e];
|
||||
setTimeout(() => {
|
||||
if (scroll) scroll.scrollTop = scroll.scrollHeight;
|
||||
}, 10);
|
||||
}
|
||||
let unsubscribe: () => void;
|
||||
let messages = $state([] as string[]);
|
||||
let wrapRight = $state(false);
|
||||
let autoScroll = $state(true);
|
||||
let suspended = $state(false);
|
||||
|
||||
type Props = {
|
||||
close: () => void;
|
||||
};
|
||||
let { close }: Props = $props();
|
||||
// export let close: () => void;
|
||||
function updateLog(logs: ReactiveInstance<string[]>) {
|
||||
const e = logs.value;
|
||||
if (!suspended) {
|
||||
messages = [...e];
|
||||
setTimeout(() => {
|
||||
if (scroll) scroll.scrollTop = scroll.scrollHeight;
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
onMount(async () => {
|
||||
const _logMessages = reactive(() => logMessages.value);
|
||||
_logMessages.onChanged(updateLog);
|
||||
Logger(msg("logPane.logWindowOpened", {}, lang));
|
||||
unsubscribe = () => _logMessages.offChanged(updateLog);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
});
|
||||
let scroll: HTMLDivElement;
|
||||
function closeDialogue() {
|
||||
close();
|
||||
}
|
||||
onMount(async () => {
|
||||
logMessages.onChanged(updateLog);
|
||||
Logger("Log window opened");
|
||||
unsubscribe = () => logMessages.offChanged(updateLog);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
});
|
||||
let scroll: HTMLDivElement;
|
||||
</script>
|
||||
|
||||
<div class="logpane">
|
||||
<!-- <h1>Self-hosted LiveSync Log</h1> -->
|
||||
<div class="control">
|
||||
<div class="row">
|
||||
<label><input type="checkbox" bind:checked={wrapRight} /><span>Wrap</span></label>
|
||||
<label><input type="checkbox" bind:checked={autoScroll} /><span>Auto scroll</span></label>
|
||||
<label><input type="checkbox" bind:checked={suspended} /><span>Pause</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log" bind:this={scroll}>
|
||||
{#each messages as line}
|
||||
<pre class:wrap-right={wrapRight}>{line}</pre>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- <h1>{msg("logPane.title", {}, lang)}</h1> -->
|
||||
<div class="control">
|
||||
<div class="row">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={wrapRight} />
|
||||
<span>{msg("logPane.wrap", {}, lang)}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={autoScroll} />
|
||||
<span>{msg("logPane.autoScroll", {}, lang)}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={suspended} />
|
||||
<span>{msg("logPane.pause", {}, lang)}</span>
|
||||
</label>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={() => closeDialogue()}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log" bind:this={scroll}>
|
||||
{#each messages as line}
|
||||
<pre class:wrap-right={wrapRight}>{line}</pre>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.logpane {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
.log {
|
||||
overflow-y: scroll;
|
||||
user-select: text;
|
||||
padding-bottom: 2em;
|
||||
}
|
||||
.log > pre {
|
||||
margin: 0;
|
||||
}
|
||||
.log > pre.wrap-right {
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.row > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 5em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.logpane {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
.log {
|
||||
overflow-y: scroll;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
padding-bottom: 2em;
|
||||
}
|
||||
.log > pre {
|
||||
margin: 0;
|
||||
}
|
||||
.log > pre.wrap-right {
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.row > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 5em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import {
|
||||
ItemView,
|
||||
WorkspaceLeaf
|
||||
} from "obsidian";
|
||||
import { WorkspaceLeaf } from "obsidian";
|
||||
import LogPaneComponent from "./LogPane.svelte";
|
||||
import type ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import { SvelteItemView } from "../../../common/SvelteItemView.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import { mount } from "svelte";
|
||||
export const VIEW_TYPE_LOG = "log-log";
|
||||
//Log view
|
||||
export class LogPaneView extends ItemView {
|
||||
export class LogPaneView extends SvelteItemView {
|
||||
instantiateComponent(target: HTMLElement) {
|
||||
return mount(LogPaneComponent, {
|
||||
target: target,
|
||||
props: {
|
||||
close: () => {
|
||||
this.leaf.detach();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
component?: LogPaneComponent;
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon = "view-log";
|
||||
title: string = "";
|
||||
navigation = true;
|
||||
navigation = false;
|
||||
|
||||
getIcon(): string {
|
||||
return "view-log";
|
||||
@@ -23,26 +32,12 @@ export class LogPaneView extends ItemView {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_LOG;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Self-hosted LiveSync Log";
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
this.component = new LogPaneComponent({
|
||||
target: this.contentEl,
|
||||
props: {
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
this.component?.$destroy();
|
||||
await Promise.resolve();
|
||||
// TODO: This function is not reactive and does not update the title based on the current language
|
||||
return $msg("logPane.title");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { VIEW_TYPE_GLOBAL_HISTORY, GlobalHistoryView } from "./GlobalHistory/GlobalHistoryView.ts";
|
||||
|
||||
|
||||
export class ModuleObsidianGlobalHistory extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-global-history",
|
||||
name: "Show vault history",
|
||||
callback: () => {
|
||||
this.showGlobalHistory()
|
||||
}
|
||||
})
|
||||
this.showGlobalHistory();
|
||||
},
|
||||
});
|
||||
|
||||
this.registerView(
|
||||
VIEW_TYPE_GLOBAL_HISTORY,
|
||||
(leaf) => new GlobalHistoryView(leaf, this.plugin)
|
||||
);
|
||||
this.registerView(VIEW_TYPE_GLOBAL_HISTORY, (leaf) => new GlobalHistoryView(leaf, this.plugin));
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
@@ -25,5 +19,4 @@ export class ModuleObsidianGlobalHistory extends AbstractObsidianModule implemen
|
||||
showGlobalHistory() {
|
||||
void this.core.$$showView(VIEW_TYPE_GLOBAL_HISTORY);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { CANCELLED, LEAVE_TO_SUBSEQUENT, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, MISSING_OR_ERROR, type DocumentID, type FilePathWithPrefix, type diff_result } from "../../lib/src/common/types.ts";
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { ConflictResolveModal } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { displayRev, getPath, getPathWithoutPrefix } from "../../common/utils.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
|
||||
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-conflictcheck",
|
||||
@@ -13,14 +22,14 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule im
|
||||
callback: async () => {
|
||||
await this.pickFileForResolve();
|
||||
},
|
||||
})
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-all-conflictcheck",
|
||||
name: "Resolve all conflicted files",
|
||||
callback: async () => {
|
||||
await this.allConflictCheck();
|
||||
},
|
||||
})
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -50,18 +59,26 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule im
|
||||
// Create a new file by concatenating both conflicted revisions.
|
||||
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
|
||||
const delRev = testDoc._conflicts[0];
|
||||
if (!await this.core.databaseFileAccess.storeContent(filename, p)) {
|
||||
if (!(await this.core.databaseFileAccess.storeContent(filename, p))) {
|
||||
this._log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
if (await this.core.$$resolveConflictByDeletingRev(filename, delRev, "UI Concatenated") == MISSING_OR_ERROR) {
|
||||
this._log(`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`, LOG_LEVEL_NOTICE);
|
||||
if (
|
||||
(await this.core.$$resolveConflictByDeletingRev(filename, delRev, "UI Concatenated")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
this._log(
|
||||
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else if (typeof toDelete === "string") {
|
||||
// Select one of the conflicted revision to delete.
|
||||
if (await this.core.$$resolveConflictByDeletingRev(filename, toDelete, "UI Selected") == MISSING_OR_ERROR) {
|
||||
if (
|
||||
(await this.core.$$resolveConflictByDeletingRev(filename, toDelete, "UI Selected")) == MISSING_OR_ERROR
|
||||
) {
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
@@ -72,8 +89,8 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule im
|
||||
// In here, some merge has been processed.
|
||||
// So we have to run replication if configured.
|
||||
// TODO: Make this is as a event request
|
||||
if (this.settings.syncAfterMerge && !this.plugin.suspended) {
|
||||
await this.core.$$waitForReplicationOnce();
|
||||
if (this.settings.syncAfterMerge && !this.core.$$isSuspended()) {
|
||||
await this.core.$$replicateByEvent();
|
||||
}
|
||||
// And, check it again.
|
||||
await this.core.$$queueConflictCheck(filename);
|
||||
@@ -83,22 +100,21 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule im
|
||||
while (await this.pickFileForResolve());
|
||||
}
|
||||
|
||||
|
||||
async pickFileForResolve() {
|
||||
const notes: { id: DocumentID, path: FilePathWithPrefix, dispPath: string, mtime: number }[] = [];
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ id: doc._id, path: getPath(doc), dispPath: getPathWithoutPrefix(doc), mtime: doc.mtime });
|
||||
}
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map(e => e.dispPath);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
const target = await this.plugin.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (target) {
|
||||
const targetItem = notes.find(e => e.dispPath == target)!;
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await this.core.$$queueConflictCheck(targetItem.path);
|
||||
await this.core.$$waitForAllConflictProcessed();
|
||||
return true;
|
||||
@@ -106,51 +122,34 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule im
|
||||
return false;
|
||||
}
|
||||
|
||||
// async resolveConflictByNewerEntry(path: FilePathWithPrefix) {
|
||||
// const id = await this.plugin.$$path2id(path);
|
||||
// const doc = await this.localDatabase.getRaw<AnyEntry>(id, { conflicts: true });
|
||||
// // If there is no conflict, return with false.
|
||||
// if (!("_conflicts" in doc) || doc._conflicts === undefined) return false;
|
||||
// if (doc._conflicts.length == 0) return false;
|
||||
// this._log(`Hidden file conflicted:${getPath(doc)}`);
|
||||
// const conflicts = doc._conflicts.sort((a, b) => Number(a.split("-")[0]) - Number(b.split("-")[0]));
|
||||
// const revA = doc._rev;
|
||||
// const revB = conflicts[0];
|
||||
// const revBDoc = await this.localDatabase.getRaw<EntryDoc>(id, { rev: revB });
|
||||
// // determine which revision should been deleted.
|
||||
// // simply check modified time
|
||||
// const mtimeA = ("mtime" in doc && doc.mtime) || 0;
|
||||
// const mtimeB = ("mtime" in revBDoc && revBDoc.mtime) || 0;
|
||||
// const delRev = mtimeA < mtimeB ? revA : revB;
|
||||
// // delete older one.
|
||||
// await this.localDatabase.removeRevision(id, delRev);
|
||||
// this._log(`Older one has been deleted:${getPath(doc)}`);
|
||||
// return true;
|
||||
// }
|
||||
|
||||
async $allScanStat(): Promise<boolean> {
|
||||
const notes: { path: string, mtime: number }[] = [];
|
||||
const notes: { path: string; mtime: number }[] = [];
|
||||
this._log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ path: getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
this.plugin.confirm.askInPopup(`conflicting-detected-on-safety`, `Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`, (anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(() => this.allConflictCheck())
|
||||
});
|
||||
}
|
||||
this.core.confirm.askInPopup(
|
||||
`conflicting-detected-on-safety`,
|
||||
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
|
||||
(anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(() => this.allConflictCheck());
|
||||
});
|
||||
}
|
||||
);
|
||||
this._log(
|
||||
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
this._log(`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`, LOG_LEVEL_VERBOSE);
|
||||
for (const note of notes) {
|
||||
this._log(`Conflicted: ${note.path}`);
|
||||
}
|
||||
} else {
|
||||
this._log(`There are no conflicted files`, LOG_LEVEL_VERBOSE);
|
||||
this._log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { computed, reactive, reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE, PREFIXMD_LOGFILE, type DatabaseConnectingStatus, type LOG_LEVEL } from "../../lib/src/common/types.ts";
|
||||
import {
|
||||
LOG_LEVEL_DEBUG,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
PREFIXMD_LOGFILE,
|
||||
type DatabaseConnectingStatus,
|
||||
type LOG_LEVEL,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { cancelTask, scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { fireAndForget, isDirty, throttle } from "../../lib/src/common/utils.ts";
|
||||
import { collectingChunks, pluginScanningCount, hiddenFilesEventCount, hiddenFilesProcessingCount, type LogEntry, logStore, logMessages } from "../../lib/src/mock_and_interop/stores.ts";
|
||||
import {
|
||||
collectingChunks,
|
||||
pluginScanningCount,
|
||||
hiddenFilesEventCount,
|
||||
hiddenFilesProcessingCount,
|
||||
type LogEntry,
|
||||
logStore,
|
||||
logMessages,
|
||||
} from "../../lib/src/mock_and_interop/stores.ts";
|
||||
import { eventHub } from "../../lib/src/hub/hub.ts";
|
||||
import { EVENT_FILE_RENAMED, EVENT_LAYOUT_READY, EVENT_LEAF_ACTIVE_CHANGED } from "../../common/events.ts";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
@@ -11,6 +26,8 @@ import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import { P2PLogCollector } from "../../lib/src/replication/trystero/P2PReplicatorCore.ts";
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
|
||||
@@ -22,15 +39,18 @@ setGlobalLogFunction((message: any, level?: number, key?: string) => {
|
||||
let recentLogs = [] as string[];
|
||||
|
||||
// Recent log splicer
|
||||
const recentLogProcessor = new QueueProcessor((logs: string[]) => {
|
||||
recentLogs = [...recentLogs, ...logs].splice(-200);
|
||||
logMessages.value = recentLogs;
|
||||
}, { batchSize: 25, delay: 10, suspended: false, concurrentLimit: 1 }).resumePipeLine();
|
||||
const recentLogProcessor = new QueueProcessor(
|
||||
(logs: string[]) => {
|
||||
recentLogs = [...recentLogs, ...logs].splice(-200);
|
||||
logMessages.value = recentLogs;
|
||||
},
|
||||
{ batchSize: 25, delay: 10, suspended: false, concurrentLimit: 1 }
|
||||
).resumePipeLine();
|
||||
// logStore.intercept(e => e.slice(Math.min(e.length - 200, 0)));
|
||||
|
||||
|
||||
const showDebugLog = false;
|
||||
export const MARK_DONE = "\u{2009}\u{2009}";
|
||||
export class ModuleLog extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
registerView = this.plugin.registerView.bind(this.plugin);
|
||||
|
||||
statusBar?: HTMLElement;
|
||||
@@ -41,9 +61,10 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
logHistory?: HTMLDivElement;
|
||||
messageArea?: HTMLDivElement;
|
||||
|
||||
statusBarLabels!: ReactiveValue<{ message: string, status: string }>;
|
||||
statusBarLabels!: ReactiveValue<{ message: string; status: string }>;
|
||||
statusLog = reactiveSource("");
|
||||
notifies: { [key: string]: { notice: Notice; count: number } } = {};
|
||||
p2pLogCollector = new P2PLogCollector();
|
||||
|
||||
observeForLogs() {
|
||||
const padSpaces = `\u{2007}`.repeat(10);
|
||||
@@ -52,7 +73,7 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
const formatted = reactiveSource("");
|
||||
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
let maxLen = 1;
|
||||
numI.onChanged(numX => {
|
||||
numI.onChanged((numX) => {
|
||||
const num = numX.value;
|
||||
const numLen = `${Math.abs(num)}`.length + 1;
|
||||
maxLen = maxLen < numLen ? numLen : maxLen;
|
||||
@@ -63,30 +84,30 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
maxLen = 1;
|
||||
}, 3000);
|
||||
}
|
||||
formatted.value = ` ${mark}${`${padSpaces}${num}`.slice(-(maxLen))}`;
|
||||
})
|
||||
formatted.value = ` ${mark}${`${padSpaces}${num}`.slice(-maxLen)}`;
|
||||
});
|
||||
return computed(() => formatted.value);
|
||||
}
|
||||
const labelReplication = padLeftSpComputed(this.plugin.replicationResultCount, `📥`);
|
||||
const labelDBCount = padLeftSpComputed(this.plugin.databaseQueueCount, `📄`);
|
||||
const labelStorageCount = padLeftSpComputed(this.plugin.storageApplyingCount, `💾`);
|
||||
const labelReplication = padLeftSpComputed(this.core.replicationResultCount, `📥`);
|
||||
const labelDBCount = padLeftSpComputed(this.core.databaseQueueCount, `📄`);
|
||||
const labelStorageCount = padLeftSpComputed(this.core.storageApplyingCount, `💾`);
|
||||
const labelChunkCount = padLeftSpComputed(collectingChunks, `🧩`);
|
||||
const labelPluginScanCount = padLeftSpComputed(pluginScanningCount, `🔌`);
|
||||
const labelConflictProcessCount = padLeftSpComputed(this.plugin.conflictProcessQueueCount, `🔩`);
|
||||
const hiddenFilesCount = reactive(() => hiddenFilesEventCount.value + hiddenFilesProcessingCount.value);
|
||||
const labelHiddenFilesCount = padLeftSpComputed(hiddenFilesCount, `⚙️`)
|
||||
const labelConflictProcessCount = padLeftSpComputed(this.core.conflictProcessQueueCount, `🔩`);
|
||||
const hiddenFilesCount = reactive(() => hiddenFilesEventCount.value - hiddenFilesProcessingCount.value);
|
||||
const labelHiddenFilesCount = padLeftSpComputed(hiddenFilesCount, `⚙️`);
|
||||
const queueCountLabelX = reactive(() => {
|
||||
return `${labelReplication()}${labelDBCount()}${labelStorageCount()}${labelChunkCount()}${labelPluginScanCount()}${labelHiddenFilesCount()}${labelConflictProcessCount()}`;
|
||||
})
|
||||
});
|
||||
const queueCountLabel = () => queueCountLabelX.value;
|
||||
|
||||
const requestingStatLabel = computed(() => {
|
||||
const diff = this.plugin.requestCount.value - this.plugin.responseCount.value;
|
||||
const diff = this.core.requestCount.value - this.core.responseCount.value;
|
||||
return diff != 0 ? "📲 " : "";
|
||||
})
|
||||
});
|
||||
|
||||
const replicationStatLabel = computed(() => {
|
||||
const e = this.plugin.replicationStat.value;
|
||||
const e = this.core.replicationStat.value;
|
||||
const sent = e.sent;
|
||||
const arrived = e.arrived;
|
||||
const maxPullSeq = e.maxPullSeq;
|
||||
@@ -97,10 +118,10 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
let pullLast = "";
|
||||
let w = "";
|
||||
const labels: Partial<Record<DatabaseConnectingStatus, string>> = {
|
||||
"CONNECTED": "⚡",
|
||||
"JOURNAL_SEND": "📦↑",
|
||||
"JOURNAL_RECEIVE": "📦↓",
|
||||
}
|
||||
CONNECTED: "⚡",
|
||||
JOURNAL_SEND: "📦↑",
|
||||
JOURNAL_RECEIVE: "📦↓",
|
||||
};
|
||||
switch (e.syncStatus) {
|
||||
case "CLOSED":
|
||||
case "COMPLETED":
|
||||
@@ -117,8 +138,18 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
case "JOURNAL_SEND":
|
||||
case "JOURNAL_RECEIVE":
|
||||
w = labels[e.syncStatus] || "⚡";
|
||||
pushLast = ((lastSyncPushSeq == 0) ? "" : (lastSyncPushSeq >= maxPushSeq ? " (LIVE)" : ` (${maxPushSeq - lastSyncPushSeq})`));
|
||||
pullLast = ((lastSyncPullSeq == 0) ? "" : (lastSyncPullSeq >= maxPullSeq ? " (LIVE)" : ` (${maxPullSeq - lastSyncPullSeq})`));
|
||||
pushLast =
|
||||
lastSyncPushSeq == 0
|
||||
? ""
|
||||
: lastSyncPushSeq >= maxPushSeq
|
||||
? " (LIVE)"
|
||||
: ` (${maxPushSeq - lastSyncPushSeq})`;
|
||||
pullLast =
|
||||
lastSyncPullSeq == 0
|
||||
? ""
|
||||
: lastSyncPullSeq >= maxPullSeq
|
||||
? " (LIVE)"
|
||||
: ` (${maxPullSeq - lastSyncPullSeq})`;
|
||||
break;
|
||||
case "ERRORED":
|
||||
w = "⚠";
|
||||
@@ -127,44 +158,49 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
w = "?";
|
||||
}
|
||||
return { w, sent, pushLast, arrived, pullLast };
|
||||
})
|
||||
const labelProc = padLeftSpComputed(this.plugin.processing, `⏳`);
|
||||
const labelPend = padLeftSpComputed(this.plugin.totalQueued, `🛫`);
|
||||
const labelInBatchDelay = padLeftSpComputed(this.plugin.batched, `📬`);
|
||||
});
|
||||
const labelProc = padLeftSpComputed(this.core.processing, `⏳`);
|
||||
const labelPend = padLeftSpComputed(this.core.totalQueued, `🛫`);
|
||||
const labelInBatchDelay = padLeftSpComputed(this.core.batched, `📬`);
|
||||
const waitingLabel = computed(() => {
|
||||
return `${labelProc()}${labelPend()}${labelInBatchDelay()}`;
|
||||
})
|
||||
});
|
||||
const statusLineLabel = computed(() => {
|
||||
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel();
|
||||
const queued = queueCountLabel();
|
||||
const waiting = waitingLabel();
|
||||
const networkActivity = requestingStatLabel();
|
||||
const p2p = this.p2pLogCollector.p2pReplicationLine.value;
|
||||
return {
|
||||
message: `${networkActivity}Sync: ${w} ↑ ${sent}${pushLast} ↓ ${arrived}${pullLast}${waiting}${queued}`,
|
||||
message: `${networkActivity}Sync: ${w} ↑ ${sent}${pushLast} ↓ ${arrived}${pullLast}${waiting}${queued}${p2p == "" ? "" : "\n" + p2p}`,
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
const statusBarLabels = reactive(() => {
|
||||
const scheduleMessage = this.plugin.isReloadingScheduled ? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n` : "";
|
||||
const scheduleMessage = this.core.$$isReloadingScheduled()
|
||||
? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n`
|
||||
: "";
|
||||
const { message } = statusLineLabel();
|
||||
const status = scheduleMessage + this.statusLog.value;
|
||||
|
||||
return {
|
||||
message, status
|
||||
}
|
||||
})
|
||||
message,
|
||||
status,
|
||||
};
|
||||
});
|
||||
this.statusBarLabels = statusBarLabels;
|
||||
|
||||
const applyToDisplay = throttle((label: typeof statusBarLabels.value) => {
|
||||
// const v = label;
|
||||
this.applyStatusBarText();
|
||||
|
||||
}, 20);
|
||||
statusBarLabels.onChanged(label => applyToDisplay(label.value))
|
||||
statusBarLabels.onChanged((label) => applyToDisplay(label.value));
|
||||
}
|
||||
|
||||
$everyOnload(): Promise<boolean> {
|
||||
eventHub.onEvent(EVENT_LEAF_ACTIVE_CHANGED, () => this.onActiveLeafChange());
|
||||
eventHub.onceEvent(EVENT_LAYOUT_READY, () => this.onActiveLeafChange());
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
adjustStatusDivPosition() {
|
||||
@@ -181,11 +217,14 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
const thisFile = this.app.workspace.getActiveFile();
|
||||
if (!thisFile) return "";
|
||||
// Case Sensitivity
|
||||
if (this.core.shouldCheckCaseInsensitive) {
|
||||
const f = this.core.storageAccess.getFiles().map(e => e.path).filter(e => e.toLowerCase() == thisFile.path.toLowerCase());
|
||||
if (this.core.$$shouldCheckCaseInsensitive()) {
|
||||
const f = this.core.storageAccess
|
||||
.getFiles()
|
||||
.map((e) => e.path)
|
||||
.filter((e) => e.toLowerCase() == thisFile.path.toLowerCase());
|
||||
if (f.length > 1) return "Not synchronised: There are multiple files with the same name";
|
||||
}
|
||||
if (!await this.core.$$isTargetFile(thisFile.path)) return "Not synchronised: not a target file";
|
||||
if (!(await this.core.$$isTargetFile(thisFile.path))) return "Not synchronised: not a target file";
|
||||
if (this.core.$$isFileSizeExceeded(thisFile.stat.size)) return "Not synchronised: File size exceeded";
|
||||
return "";
|
||||
}
|
||||
@@ -197,11 +236,10 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
this.adjustStatusDivPosition();
|
||||
await this.setFileStatus();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
nextFrameQueue: ReturnType<typeof requestAnimationFrame> | undefined = undefined;
|
||||
logLines: { ttl: number, message: string }[] = [];
|
||||
logLines: { ttl: number; message: string }[] = [];
|
||||
|
||||
applyStatusBarText() {
|
||||
if (this.nextFrameQueue) {
|
||||
@@ -222,10 +260,13 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
if (this.settings?.showStatusOnEditor && this.statusDiv) {
|
||||
if (this.settings.showLongerLogInsideEditor) {
|
||||
const now = new Date().getTime();
|
||||
this.logLines = this.logLines.filter(e => e.ttl > now);
|
||||
const minimumNext = this.logLines.reduce((a, b) => a < b.ttl ? a : b.ttl, Number.MAX_SAFE_INTEGER);
|
||||
this.logLines = this.logLines.filter((e) => e.ttl > now);
|
||||
const minimumNext = this.logLines.reduce(
|
||||
(a, b) => (a < b.ttl ? a : b.ttl),
|
||||
Number.MAX_SAFE_INTEGER
|
||||
);
|
||||
if (this.logLines.length > 0) setTimeout(() => this.applyStatusBarText(), minimumNext - now);
|
||||
const recent = this.logLines.map(e => e.message);
|
||||
const recent = this.logLines.map((e) => e.message);
|
||||
const recentLogs = recent.reverse().join("\n");
|
||||
if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs;
|
||||
}
|
||||
@@ -237,14 +278,16 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
}
|
||||
});
|
||||
|
||||
scheduleTask("log-hide", 3000, () => { this.statusLog.value = "" });
|
||||
scheduleTask("log-hide", 3000, () => {
|
||||
this.statusLog.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
$allStartOnUnload(): Promise<boolean> {
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.remove();
|
||||
}
|
||||
document.querySelectorAll(`.livesync-status`)?.forEach(e => e.remove());
|
||||
document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
@@ -255,7 +298,7 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
<path d="m106 346v44h70v-44zm45 16h-20v-8h20z"/>
|
||||
</g>`
|
||||
);
|
||||
this.addRibbonIcon("view-log", "Show log", () => {
|
||||
this.addRibbonIcon("view-log", $msg("moduleLog.showLog"), () => {
|
||||
void this.core.$$showView(VIEW_TYPE_LOG);
|
||||
}).addClass("livesync-ribbon-showlog");
|
||||
|
||||
@@ -264,22 +307,28 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
name: "Show log",
|
||||
callback: () => {
|
||||
void this.core.$$showView(VIEW_TYPE_LOG);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.registerView(
|
||||
VIEW_TYPE_LOG,
|
||||
(leaf) => new LogPaneView(leaf, this.plugin)
|
||||
);
|
||||
this.registerView(VIEW_TYPE_LOG, (leaf) => new LogPaneView(leaf, this.plugin));
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
$everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
logStore.pipeTo(new QueueProcessor(logs => logs.forEach(e => this.core.$$addLog(e.message, e.level, e.key)), { suspended: false, batchSize: 20, concurrentLimit: 1, delay: 0 })).startPipeline();
|
||||
eventHub.onEvent(EVENT_FILE_RENAMED, (evt: CustomEvent<{ oldPath: string, newPath: string }>) => {
|
||||
logStore
|
||||
.pipeTo(
|
||||
new QueueProcessor((logs) => logs.forEach((e) => this.core.$$addLog(e.message, e.level, e.key)), {
|
||||
suspended: false,
|
||||
batchSize: 20,
|
||||
concurrentLimit: 1,
|
||||
delay: 0,
|
||||
})
|
||||
)
|
||||
.startPipeline();
|
||||
eventHub.onEvent(EVENT_FILE_RENAMED, (data) => {
|
||||
void this.setFileStatus();
|
||||
});
|
||||
|
||||
const w = document.querySelectorAll(`.livesync-status`);
|
||||
w.forEach(e => e.remove());
|
||||
w.forEach((e) => e.remove());
|
||||
|
||||
this.observeForLogs();
|
||||
|
||||
@@ -290,7 +339,7 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
this.logHistory = this.statusDiv.createDiv({ cls: "livesync-status-loghistory" });
|
||||
eventHub.onEvent(EVENT_LAYOUT_READY, () => this.adjustStatusDivPosition());
|
||||
if (this.settings?.showStatusOnStatusbar) {
|
||||
this.statusBar = this.plugin.addStatusBarItem();
|
||||
this.statusBar = this.core.addStatusBarItem();
|
||||
this.statusBar.addClass("syncstatusbar");
|
||||
}
|
||||
this.adjustStatusDivPosition();
|
||||
@@ -298,18 +347,23 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
}
|
||||
|
||||
writeLogToTheFile(now: Date, vaultName: string, newMessage: string) {
|
||||
fireAndForget(() => serialized("writeLog", async () => {
|
||||
const time = now.toISOString().split("T")[0];
|
||||
const logDate = `${PREFIXMD_LOGFILE}${time}.md`;
|
||||
const file = await this.core.storageAccess.isExists(normalizePath(logDate));
|
||||
if (!file) {
|
||||
await this.core.storageAccess.appendHiddenFile(normalizePath(logDate), "```\n");
|
||||
}
|
||||
await this.core.storageAccess.appendHiddenFile(normalizePath(logDate), vaultName + ":" + newMessage + "\n");
|
||||
}));
|
||||
fireAndForget(() =>
|
||||
serialized("writeLog", async () => {
|
||||
const time = now.toISOString().split("T")[0];
|
||||
const logDate = `${PREFIXMD_LOGFILE}${time}.md`;
|
||||
const file = await this.core.storageAccess.isExists(normalizePath(logDate));
|
||||
if (!file) {
|
||||
await this.core.storageAccess.appendHiddenFile(normalizePath(logDate), "```\n");
|
||||
}
|
||||
await this.core.storageAccess.appendHiddenFile(
|
||||
normalizePath(logDate),
|
||||
vaultName + ":" + newMessage + "\n"
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
$$addLog(message: any, level: LOG_LEVEL = LOG_LEVEL_INFO, key = ""): void {
|
||||
if (level == LOG_LEVEL_DEBUG) {
|
||||
if (level == LOG_LEVEL_DEBUG && !showDebugLog) {
|
||||
return;
|
||||
}
|
||||
if (level < LOG_LEVEL_INFO && this.settings && this.settings.lessInformationInLog) {
|
||||
@@ -318,10 +372,15 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
if (this.settings && !this.settings.showVerboseLog && level == LOG_LEVEL_VERBOSE) {
|
||||
return;
|
||||
}
|
||||
const vaultName = this.plugin.$$getVaultName();
|
||||
const vaultName = this.core.$$getVaultName();
|
||||
const now = new Date();
|
||||
const timestamp = now.toLocaleString();
|
||||
const messageContent = typeof message == "string" ? message : message instanceof Error ? `${message.name}:${message.message}` : JSON.stringify(message, null, 2);
|
||||
const messageContent =
|
||||
typeof message == "string"
|
||||
? message
|
||||
: message instanceof Error
|
||||
? `${message.name}:${message.message}`
|
||||
: JSON.stringify(message, null, 2);
|
||||
if (message instanceof Error) {
|
||||
// debugger;
|
||||
console.dir(message.stack);
|
||||
@@ -342,7 +401,7 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
if (!key) key = messageContent;
|
||||
if (key in this.notifies) {
|
||||
// @ts-ignore
|
||||
const isShown = this.notifies[key].notice.noticeEl?.isShown()
|
||||
const isShown = this.notifies[key].notice.noticeEl?.isShown();
|
||||
if (!isShown) {
|
||||
this.notifies[key].notice = new Notice(messageContent, 0);
|
||||
}
|
||||
@@ -361,17 +420,17 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
};
|
||||
}
|
||||
const timeout = 5000;
|
||||
scheduleTask(`notify-${key}`, timeout, () => {
|
||||
const notify = this.notifies[key].notice;
|
||||
delete this.notifies[key];
|
||||
try {
|
||||
notify.hide();
|
||||
} catch {
|
||||
// NO OP
|
||||
}
|
||||
})
|
||||
if (!key.startsWith("keepalive-") || messageContent.indexOf(MARK_DONE) !== -1) {
|
||||
scheduleTask(`notify-${key}`, timeout, () => {
|
||||
const notify = this.notifies[key].notice;
|
||||
delete this.notifies[key];
|
||||
try {
|
||||
notify.hide();
|
||||
} catch {
|
||||
// NO OP
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { type TFile } from "obsidian";
|
||||
import { eventHub, EVENT_REQUEST_SHOW_HISTORY } from "../../common/events.ts";
|
||||
import { eventHub } from "../../common/events.ts";
|
||||
import { EVENT_REQUEST_SHOW_HISTORY } from "../../common/obsidianEvents.ts";
|
||||
import type { FilePathWithPrefix, LoadedEntry, DocumentID } from "../../lib/src/common/types.ts";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { DocumentHistoryModal } from "./DocumentHistory/DocumentHistoryModal.ts";
|
||||
import { getPath } from "../../common/utils.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
|
||||
|
||||
|
||||
export class ModuleObsidianDocumentHistory extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-history",
|
||||
name: "Show history",
|
||||
callback: () => {
|
||||
const file = this.core.$$getActiveFilePath();
|
||||
if (file) this.showHistory(file, undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
@@ -29,9 +25,12 @@ export class ModuleObsidianDocumentHistory extends AbstractObsidianModule implem
|
||||
fireAndForget(async () => await this.fileHistory());
|
||||
},
|
||||
});
|
||||
eventHub.on(EVENT_REQUEST_SHOW_HISTORY, ({ file, fileOnDB }: { file: TFile, fileOnDB: LoadedEntry }) => {
|
||||
this.showHistory(file, fileOnDB._id);
|
||||
})
|
||||
eventHub.onEvent(
|
||||
EVENT_REQUEST_SHOW_HISTORY,
|
||||
({ file, fileOnDB }: { file: TFile | FilePathWithPrefix; fileOnDB: LoadedEntry }) => {
|
||||
this.showHistory(file, fileOnDB._id);
|
||||
}
|
||||
);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -40,17 +39,16 @@ export class ModuleObsidianDocumentHistory extends AbstractObsidianModule implem
|
||||
}
|
||||
|
||||
async fileHistory() {
|
||||
const notes: { id: DocumentID, path: FilePathWithPrefix, dispPath: string, mtime: number }[] = [];
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of this.localDatabase.findAllDocs()) {
|
||||
notes.push({ id: doc._id, path: getPath(doc), dispPath: getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map(e => e.dispPath);
|
||||
const target = await this.plugin.confirm.askSelectString("File to view History", notesList);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
const target = await this.core.confirm.askSelectString("File to view History", notesList);
|
||||
if (target) {
|
||||
const targetId = notes.find(e => e.dispPath == target)!;
|
||||
const targetId = notes.find((e) => e.dispPath == target)!;
|
||||
this.showHistory(targetId.path, targetId.id);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { type IObsidianModule, AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED, eventHub } from "../../common/events";
|
||||
import { type BucketSyncSetting, type ConfigPassphraseStore, type CouchDBConnection, DEFAULT_SETTINGS, type ObsidianLiveSyncSettings, SALT_OF_PASSPHRASE } from "../../lib/src/common/types";
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
type ConfigPassphraseStore,
|
||||
type CouchDBConnection,
|
||||
DEFAULT_SETTINGS,
|
||||
type ObsidianLiveSyncSettings,
|
||||
SALT_OF_PASSPHRASE,
|
||||
} from "../../lib/src/common/types";
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||
import { encrypt, tryDecrypt } from "octagonal-wheels/encryption";
|
||||
import { setLang } from "../../lib/src/common/i18n";
|
||||
import { isCloudantURI } from "../../lib/src/pouchdb/utils_couchdb";
|
||||
export class ModuleObsidianSettings extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
getPassphrase(settings: ObsidianLiveSyncSettings) {
|
||||
const methods: Record<ConfigPassphraseStore, (() => Promise<string | false>)> = {
|
||||
const methods: Record<ConfigPassphraseStore, () => Promise<string | false>> = {
|
||||
"": () => Promise.resolve("*"),
|
||||
"LOCALSTORAGE": () => Promise.resolve(localStorage.getItem("ls-setting-passphrase") ?? false),
|
||||
"ASK_AT_LAUNCH": () => this.plugin.confirm.askString("Passphrase", "passphrase", "")
|
||||
}
|
||||
LOCALSTORAGE: () => Promise.resolve(localStorage.getItem("ls-setting-passphrase") ?? false),
|
||||
ASK_AT_LAUNCH: () => this.core.confirm.askString("Passphrase", "passphrase", ""),
|
||||
};
|
||||
const method = settings.configPassphraseStore;
|
||||
const methodFunc = method in methods ? methods[method] : methods[""];
|
||||
return methodFunc();
|
||||
}
|
||||
|
||||
$$saveDeviceAndVaultName(): void {
|
||||
const lsKey = "obsidian-live-sync-vaultanddevicename-" + this.plugin.$$getVaultName();
|
||||
localStorage.setItem(lsKey, this.plugin.deviceAndVaultName || "");
|
||||
const lsKey = "obsidian-live-sync-vaultanddevicename-" + this.core.$$getVaultName();
|
||||
localStorage.setItem(lsKey, this.core.$$getDeviceAndVaultName() || "");
|
||||
}
|
||||
|
||||
usedPassphrase = "";
|
||||
@@ -29,7 +35,6 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
this.usedPassphrase = "";
|
||||
}
|
||||
|
||||
|
||||
async decryptConfigurationItem(encrypted: string, passphrase: string) {
|
||||
const dec = await tryDecrypt(encrypted, passphrase + SALT_OF_PASSPHRASE, false);
|
||||
if (dec) {
|
||||
@@ -39,7 +44,6 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
async encryptConfigurationItem(src: string, settings: ObsidianLiveSyncSettings) {
|
||||
if (this.usedPassphrase != "") {
|
||||
return await encrypt(src, this.usedPassphrase + SALT_OF_PASSPHRASE, false);
|
||||
@@ -47,7 +51,10 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
|
||||
const passphrase = await this.getPassphrase(settings);
|
||||
if (passphrase === false) {
|
||||
this._log("Could not determine passphrase to save data.json! You probably make the configuration sure again!", LOG_LEVEL_URGENT);
|
||||
this._log(
|
||||
"Failed to obtain passphrase when saving data.json! Please verify the configuration.",
|
||||
LOG_LEVEL_URGENT
|
||||
);
|
||||
return "";
|
||||
}
|
||||
const dec = await encrypt(src, passphrase + SALT_OF_PASSPHRASE, false);
|
||||
@@ -60,17 +67,22 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
}
|
||||
|
||||
get appId() {
|
||||
return `${("appId" in this.app ? this.app.appId : "")}`;
|
||||
return `${"appId" in this.app ? this.app.appId : ""}`;
|
||||
}
|
||||
|
||||
async $$saveSettingData() {
|
||||
this.plugin.$$saveDeviceAndVaultName();
|
||||
this.core.$$saveDeviceAndVaultName();
|
||||
const settings = { ...this.settings };
|
||||
settings.deviceAndVaultName = "";
|
||||
if (this.usedPassphrase == "" && !await this.getPassphrase(settings)) {
|
||||
this._log("Could not determine passphrase for saving data.json! Our data.json have insecure items!", LOG_LEVEL_NOTICE);
|
||||
if (this.usedPassphrase == "" && !(await this.getPassphrase(settings))) {
|
||||
this._log("Failed to retrieve passphrase. data.json contains unencrypted items!", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
if (settings.couchDB_PASSWORD != "" || settings.couchDB_URI != "" || settings.couchDB_USER != "" || settings.couchDB_DBNAME) {
|
||||
if (
|
||||
settings.couchDB_PASSWORD != "" ||
|
||||
settings.couchDB_URI != "" ||
|
||||
settings.couchDB_USER != "" ||
|
||||
settings.couchDB_DBNAME
|
||||
) {
|
||||
const connectionSetting: CouchDBConnection & BucketSyncSetting = {
|
||||
couchDB_DBNAME: settings.couchDB_DBNAME,
|
||||
couchDB_PASSWORD: settings.couchDB_PASSWORD,
|
||||
@@ -81,9 +93,20 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
endpoint: settings.endpoint,
|
||||
region: settings.region,
|
||||
secretKey: settings.secretKey,
|
||||
useCustomRequestHandler: settings.useCustomRequestHandler
|
||||
useCustomRequestHandler: settings.useCustomRequestHandler,
|
||||
bucketCustomHeaders: settings.bucketCustomHeaders,
|
||||
couchDB_CustomHeaders: settings.couchDB_CustomHeaders,
|
||||
useJWT: settings.useJWT,
|
||||
jwtKey: settings.jwtKey,
|
||||
jwtAlgorithm: settings.jwtAlgorithm,
|
||||
jwtKid: settings.jwtKid,
|
||||
jwtExpDuration: settings.jwtExpDuration,
|
||||
jwtSub: settings.jwtSub,
|
||||
};
|
||||
settings.encryptedCouchDBConnection = await this.encryptConfigurationItem(JSON.stringify(connectionSetting), settings);
|
||||
settings.encryptedCouchDBConnection = await this.encryptConfigurationItem(
|
||||
JSON.stringify(connectionSetting),
|
||||
settings
|
||||
);
|
||||
settings.couchDB_PASSWORD = "";
|
||||
settings.couchDB_DBNAME = "";
|
||||
settings.couchDB_URI = "";
|
||||
@@ -98,7 +121,6 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
settings.encryptedPassphrase = await this.encryptConfigurationItem(settings.passphrase, settings);
|
||||
settings.passphrase = "";
|
||||
}
|
||||
|
||||
}
|
||||
await this.core.saveData(settings);
|
||||
eventHub.emitEvent(EVENT_SETTING_SAVED, settings);
|
||||
@@ -127,7 +149,7 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
}
|
||||
const passphrase = await this.getPassphrase(settings);
|
||||
if (passphrase === false) {
|
||||
this._log("Could not determine passphrase for reading data.json! DO NOT synchronize with the remote before making sure your configuration is!", LOG_LEVEL_URGENT);
|
||||
this._log("No passphrase found for data.json! Verify configuration before syncing.", LOG_LEVEL_URGENT);
|
||||
} else {
|
||||
if (settings.encryptedCouchDBConnection) {
|
||||
const keys = [
|
||||
@@ -139,17 +161,23 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
"bucket",
|
||||
"endpoint",
|
||||
"region",
|
||||
"secretKey"] as (keyof CouchDBConnection | keyof BucketSyncSetting)[];
|
||||
const decrypted = this.tryDecodeJson(await this.decryptConfigurationItem(settings.encryptedCouchDBConnection, passphrase)) as (CouchDBConnection & BucketSyncSetting);
|
||||
"secretKey",
|
||||
] as (keyof CouchDBConnection | keyof BucketSyncSetting)[];
|
||||
const decrypted = this.tryDecodeJson(
|
||||
await this.decryptConfigurationItem(settings.encryptedCouchDBConnection, passphrase)
|
||||
) as CouchDBConnection & BucketSyncSetting;
|
||||
if (decrypted) {
|
||||
for (const key of keys) {
|
||||
if (key in decrypted) {
|
||||
//@ts-ignore
|
||||
settings[key] = decrypted[key]
|
||||
settings[key] = decrypted[key];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._log("Could not decrypt passphrase for reading data.json! DO NOT synchronize with the remote before making sure your configuration is!", LOG_LEVEL_URGENT);
|
||||
this._log(
|
||||
"Failed to decrypt passphrase from data.json! Ensure configuration is correct before syncing with remote.",
|
||||
LOG_LEVEL_URGENT
|
||||
);
|
||||
for (const key of keys) {
|
||||
//@ts-ignore
|
||||
settings[key] = "";
|
||||
@@ -162,11 +190,13 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
if (decrypted) {
|
||||
settings.passphrase = decrypted;
|
||||
} else {
|
||||
this._log("Could not decrypt passphrase for reading data.json! DO NOT synchronize with the remote before making sure your configuration is!", LOG_LEVEL_URGENT);
|
||||
this._log(
|
||||
"Failed to decrypt passphrase from data.json! Ensure configuration is correct before syncing with remote.",
|
||||
LOG_LEVEL_URGENT
|
||||
);
|
||||
settings.passphrase = "";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
this.settings = settings;
|
||||
setLang(this.settings.displayLanguage);
|
||||
@@ -182,26 +212,29 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
// So, use history is always enabled.
|
||||
this.settings.useHistory = true;
|
||||
|
||||
const lsKey = "obsidian-live-sync-vaultanddevicename-" + this.plugin.$$getVaultName();
|
||||
const lsKey = "obsidian-live-sync-vaultanddevicename-" + this.core.$$getVaultName();
|
||||
if (this.settings.deviceAndVaultName != "") {
|
||||
if (!localStorage.getItem(lsKey)) {
|
||||
this.core.deviceAndVaultName = this.settings.deviceAndVaultName;
|
||||
localStorage.setItem(lsKey, this.core.deviceAndVaultName);
|
||||
this.core.$$setDeviceAndVaultName(this.settings.deviceAndVaultName);
|
||||
this.$$saveDeviceAndVaultName();
|
||||
this.settings.deviceAndVaultName = "";
|
||||
}
|
||||
}
|
||||
if (isCloudantURI(this.settings.couchDB_URI) && this.settings.customChunkSize != 0) {
|
||||
this._log("Configuration verification founds problems with your configuration. This has been fixed automatically. But you may already have data that cannot be synchronised. If this is the case, please rebuild everything.", LOG_LEVEL_NOTICE)
|
||||
this._log(
|
||||
"Configuration issues detected and automatically resolved. However, unsynchronized data may exist. Consider rebuilding if necessary.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.settings.customChunkSize = 0;
|
||||
}
|
||||
this.core.deviceAndVaultName = localStorage.getItem(lsKey) || "";
|
||||
if (this.core.deviceAndVaultName == "") {
|
||||
this.core.$$setDeviceAndVaultName(localStorage.getItem(lsKey) || "");
|
||||
if (this.core.$$getDeviceAndVaultName() == "") {
|
||||
if (this.settings.usePluginSync) {
|
||||
this._log("Device name is not set. Plug-in sync has been disabled.", LOG_LEVEL_NOTICE);
|
||||
this._log("Device name missing. Disabling plug-in sync.", LOG_LEVEL_NOTICE);
|
||||
this.settings.usePluginSync = false;
|
||||
}
|
||||
}
|
||||
// this.core.ignoreFiles = this.settings.ignoreFiles.split(",").map(e => e.trim());
|
||||
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE }
|
||||
const SETTING_HEADER = "````yaml:livesync-setting\n";
|
||||
const SETTING_FOOTER = "\n````";
|
||||
export class ModuleObsidianSettingsAsMarkdown extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-export-config",
|
||||
@@ -19,10 +18,10 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractObsidianModule imp
|
||||
return this.settings.settingSyncFile != "";
|
||||
}
|
||||
fireAndForget(async () => {
|
||||
await this.plugin.$$saveSettingData();
|
||||
await this.core.$$saveSettingData();
|
||||
});
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-import-config",
|
||||
name: "Parse setting file",
|
||||
@@ -33,33 +32,33 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractObsidianModule imp
|
||||
return ret.body != "";
|
||||
}
|
||||
if (ctx.file) {
|
||||
const file = ctx.file
|
||||
const file = ctx.file;
|
||||
fireAndForget(async () => await this.checkAndApplySettingFromMarkdown(file.path, false));
|
||||
}
|
||||
},
|
||||
})
|
||||
eventHub.on("event-file-changed", (info: {
|
||||
file: FilePathWithPrefix, automated: boolean
|
||||
}) => {
|
||||
});
|
||||
eventHub.onEvent("event-file-changed", (info: { file: FilePathWithPrefix; automated: boolean }) => {
|
||||
fireAndForget(() => this.checkAndApplySettingFromMarkdown(info.file, info.automated));
|
||||
});
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (evt: CustomEvent<ObsidianLiveSyncSettings>) => {
|
||||
const settings = evt.detail;
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (settings: ObsidianLiveSyncSettings) => {
|
||||
if (settings.settingSyncFile != "") {
|
||||
fireAndForget(() => this.saveSettingToMarkdown(settings.settingSyncFile));
|
||||
}
|
||||
})
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
|
||||
extractSettingFromWholeText(data: string): {
|
||||
preamble: string, body: string, postscript: string
|
||||
preamble: string;
|
||||
body: string;
|
||||
postscript: string;
|
||||
} {
|
||||
if (data.indexOf(SETTING_HEADER) === -1) {
|
||||
return {
|
||||
preamble: data, body: "", postscript: ""
|
||||
}
|
||||
preamble: data,
|
||||
body: "",
|
||||
postscript: "",
|
||||
};
|
||||
}
|
||||
const startMarkerPos = data.indexOf(SETTING_HEADER);
|
||||
const dataStartPos = startMarkerPos == -1 ? data.length : startMarkerPos;
|
||||
@@ -69,27 +68,33 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractObsidianModule imp
|
||||
const ret = {
|
||||
preamble: data.substring(0, dataStartPos),
|
||||
body,
|
||||
postscript: data.substring(dataEndPos + SETTING_FOOTER.length + 1)
|
||||
}
|
||||
postscript: data.substring(dataEndPos + SETTING_FOOTER.length + 1),
|
||||
};
|
||||
return ret;
|
||||
}
|
||||
|
||||
async parseSettingFromMarkdown(filename: string, data?: string) {
|
||||
const file = await this.core.storageAccess.isExists(filename);
|
||||
if (!file) return {
|
||||
preamble: "", body: "", postscript: "",
|
||||
};
|
||||
if (!file)
|
||||
return {
|
||||
preamble: "",
|
||||
body: "",
|
||||
postscript: "",
|
||||
};
|
||||
if (data) {
|
||||
return this.extractSettingFromWholeText(data);
|
||||
}
|
||||
const parseData = data ?? await this.core.storageAccess.readFileText(filename);
|
||||
const parseData = data ?? (await this.core.storageAccess.readFileText(filename));
|
||||
return this.extractSettingFromWholeText(parseData);
|
||||
}
|
||||
|
||||
async checkAndApplySettingFromMarkdown(filename: string, automated?: boolean) {
|
||||
if (automated && !this.settings.notifyAllSettingSyncFile) {
|
||||
if (!this.settings.settingSyncFile || this.settings.settingSyncFile != filename) {
|
||||
this._log(`Setting file (${filename}) is not matched to the current configuration. skipped.`, LOG_LEVEL_DEBUG);
|
||||
this._log(
|
||||
`Setting file (${filename}) does not match the current configuration. skipped.`,
|
||||
LOG_LEVEL_DEBUG
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -104,68 +109,93 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractObsidianModule imp
|
||||
}
|
||||
|
||||
if ("settingSyncFile" in newSetting && newSetting.settingSyncFile != filename) {
|
||||
this._log("This setting file seems to backed up one. Please fix the filename or settingSyncFile value.", automated ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE);
|
||||
this._log(
|
||||
"This setting file seems to backed up one. Please fix the filename or settingSyncFile value.",
|
||||
automated ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let settingToApply = { ...DEFAULT_SETTINGS } as ObsidianLiveSyncSettings;
|
||||
settingToApply = { ...settingToApply, ...newSetting }
|
||||
if (!(settingToApply?.writeCredentialsForSettingSync)) {
|
||||
//New setting does not contains credentials.
|
||||
settingToApply = { ...settingToApply, ...newSetting };
|
||||
if (!settingToApply?.writeCredentialsForSettingSync) {
|
||||
//New setting does not contains credentials.
|
||||
settingToApply.couchDB_USER = this.settings.couchDB_USER;
|
||||
settingToApply.couchDB_PASSWORD = this.settings.couchDB_PASSWORD;
|
||||
settingToApply.passphrase = this.settings.passphrase;
|
||||
}
|
||||
const oldSetting = this.generateSettingForMarkdown(this.settings, settingToApply.writeCredentialsForSettingSync);
|
||||
const oldSetting = this.generateSettingForMarkdown(
|
||||
this.settings,
|
||||
settingToApply.writeCredentialsForSettingSync
|
||||
);
|
||||
if (!isObjectDifferent(oldSetting, this.generateSettingForMarkdown(settingToApply))) {
|
||||
this._log("Setting markdown has been detected, but not changed.", automated ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE);
|
||||
return
|
||||
this._log(
|
||||
"Setting markdown has been detected, but not changed.",
|
||||
automated ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
const addMsg = this.settings.settingSyncFile != filename ? " (This is not-active file)" : "";
|
||||
this.plugin.confirm.askInPopup("apply-setting-from-md", `Setting markdown ${filename}${addMsg} has been detected. Apply this from {HERE}.`, (anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
const APPLY_ONLY = "Apply settings";
|
||||
const APPLY_AND_RESTART = "Apply settings and restart obsidian";
|
||||
const APPLY_AND_REBUILD = "Apply settings and restart obsidian with red_flag_rebuild.md";
|
||||
const APPLY_AND_FETCH = "Apply settings and restart obsidian with red_flag_fetch.md";
|
||||
const CANCEL = "Cancel";
|
||||
const result = await this.plugin.confirm.askSelectStringDialogue("Ready for apply the setting.", [
|
||||
APPLY_AND_RESTART,
|
||||
APPLY_ONLY,
|
||||
APPLY_AND_FETCH,
|
||||
APPLY_AND_REBUILD,
|
||||
CANCEL], { defaultAction: APPLY_AND_RESTART });
|
||||
if (result == APPLY_ONLY || result == APPLY_AND_RESTART || result == APPLY_AND_REBUILD || result == APPLY_AND_FETCH) {
|
||||
this.plugin.settings = settingToApply;
|
||||
await this.plugin.$$saveSettingData();
|
||||
if (result == APPLY_ONLY) {
|
||||
this._log("Loaded settings have been applied!", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
this.core.confirm.askInPopup(
|
||||
"apply-setting-from-md",
|
||||
`Setting markdown ${filename}${addMsg} has been detected. Apply this from {HERE}.`,
|
||||
(anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
const APPLY_ONLY = "Apply settings";
|
||||
const APPLY_AND_RESTART = "Apply settings and restart obsidian";
|
||||
const APPLY_AND_REBUILD = "Apply settings and restart obsidian with red_flag_rebuild.md";
|
||||
const APPLY_AND_FETCH = "Apply settings and restart obsidian with red_flag_fetch.md";
|
||||
const CANCEL = "Cancel";
|
||||
const result = await this.core.confirm.askSelectStringDialogue(
|
||||
"Ready for apply the setting.",
|
||||
[APPLY_AND_RESTART, APPLY_ONLY, APPLY_AND_FETCH, APPLY_AND_REBUILD, CANCEL],
|
||||
{ defaultAction: APPLY_AND_RESTART }
|
||||
);
|
||||
if (
|
||||
result == APPLY_ONLY ||
|
||||
result == APPLY_AND_RESTART ||
|
||||
result == APPLY_AND_REBUILD ||
|
||||
result == APPLY_AND_FETCH
|
||||
) {
|
||||
this.core.settings = settingToApply;
|
||||
await this.core.$$saveSettingData();
|
||||
if (result == APPLY_ONLY) {
|
||||
this._log("Loaded settings have been applied!", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
if (result == APPLY_AND_REBUILD) {
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
}
|
||||
if (result == APPLY_AND_FETCH) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
}
|
||||
this.core.$$performRestart();
|
||||
}
|
||||
if (result == APPLY_AND_REBUILD) {
|
||||
await this.plugin.rebuilder.scheduleRebuild();
|
||||
}
|
||||
if (result == APPLY_AND_FETCH) {
|
||||
await this.plugin.rebuilder.scheduleFetch();
|
||||
}
|
||||
this.plugin.$$performRestart();
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
generateSettingForMarkdown(settings?: ObsidianLiveSyncSettings, keepCredential?: boolean): Partial<ObsidianLiveSyncSettings> {
|
||||
generateSettingForMarkdown(
|
||||
settings?: ObsidianLiveSyncSettings,
|
||||
keepCredential?: boolean
|
||||
): Partial<ObsidianLiveSyncSettings> {
|
||||
const saveData = { ...(settings ? settings : this.settings) } as Partial<ObsidianLiveSyncSettings>;
|
||||
delete saveData.encryptedCouchDBConnection;
|
||||
delete saveData.encryptedPassphrase;
|
||||
delete saveData.additionalSuffixOfDatabaseName;
|
||||
if (!saveData.writeCredentialsForSettingSync && !keepCredential) {
|
||||
delete saveData.couchDB_USER;
|
||||
delete saveData.couchDB_PASSWORD;
|
||||
delete saveData.passphrase;
|
||||
delete saveData.jwtKey;
|
||||
delete saveData.jwtKid;
|
||||
delete saveData.jwtSub;
|
||||
delete saveData.couchDB_CustomHeaders;
|
||||
delete saveData.bucketCustomHeaders;
|
||||
}
|
||||
return saveData;
|
||||
}
|
||||
@@ -174,7 +204,6 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractObsidianModule imp
|
||||
const saveData = this.generateSettingForMarkdown();
|
||||
const file = await this.core.storageAccess.isExists(filename);
|
||||
|
||||
|
||||
if (!file) {
|
||||
await this.core.storageAccess.ensureDir(filename);
|
||||
const initialContent = `This file contains Self-hosted LiveSync settings as YAML.
|
||||
@@ -188,8 +217,11 @@ We can perform a command in this file.
|
||||
**Note** Please handle it with all of your care if you have configured to write credentials in.
|
||||
|
||||
|
||||
`
|
||||
await this.core.storageAccess.writeFileAuto(filename, initialContent + SETTING_HEADER + "\n" + SETTING_FOOTER);
|
||||
`;
|
||||
await this.core.storageAccess.writeFileAuto(
|
||||
filename,
|
||||
initialContent + SETTING_HEADER + "\n" + SETTING_FOOTER
|
||||
);
|
||||
}
|
||||
// if (!(file instanceof TFile)) {
|
||||
// this._log(`Markdown Setting: ${filename} already exists as a folder`, LOG_LEVEL_NOTICE);
|
||||
@@ -203,9 +235,11 @@ We can perform a command in this file.
|
||||
if (newBody == body) {
|
||||
this._log("Markdown setting: Nothing had been changed", LOG_LEVEL_VERBOSE);
|
||||
} else {
|
||||
await this.core.storageAccess.writeFileAuto(filename, preamble + SETTING_HEADER + newBody + SETTING_FOOTER + postscript);
|
||||
await this.core.storageAccess.writeFileAuto(
|
||||
filename,
|
||||
preamble + SETTING_HEADER + newBody + SETTING_FOOTER + postscript
|
||||
);
|
||||
this._log(`Markdown setting: ${filename} has been updated!`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { ObsidianLiveSyncSettingTab } from "./SettingDialogue/ObsidianLiveSyncSettingTab.ts";
|
||||
import { type IObsidianModule, AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
|
||||
import { EVENT_REQUEST_OPEN_SETTINGS, eventHub } from "../../common/events.ts";
|
||||
import { EVENT_REQUEST_OPEN_SETTING_WIZARD, EVENT_REQUEST_OPEN_SETTINGS, eventHub } from "../../common/events.ts";
|
||||
|
||||
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule implements IObsidianModule {
|
||||
|
||||
settingTab!: ObsidianLiveSyncSettingTab;
|
||||
|
||||
$everyOnloadStart(): Promise<boolean> {
|
||||
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
|
||||
this.plugin.addSettingTab(this.settingTab);
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD, () => {
|
||||
this.openSetting();
|
||||
void this.settingTab.enableMinimalSetup();
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -23,8 +27,6 @@ export class ModuleObsidianSettingDialogue extends AbstractObsidianModule implem
|
||||
}
|
||||
|
||||
get appId() {
|
||||
return `${("appId" in this.app ? this.app.appId : "")}`;
|
||||
return `${"appId" in this.app ? this.app.appId : ""}`;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
import { type ObsidianLiveSyncSettings, DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../../lib/src/common/types.ts";
|
||||
import { configURIBase } from "../../common/types.ts";
|
||||
import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
KeyIndexOfSettings,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { configURIBase, configURIBaseQR } from "../../common/types.ts";
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.js";
|
||||
import { decrypt, encrypt } from "../../lib/src/encryption/e2ee_v2.ts";
|
||||
import { fireAndForget } from "../../lib/src/common/utils.ts";
|
||||
import { EVENT_REQUEST_COPY_SETUP_URI, EVENT_REQUEST_OPEN_SETUP_URI, eventHub } from "../../common/events.ts";
|
||||
import {
|
||||
EVENT_REQUEST_COPY_SETUP_URI,
|
||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
||||
EVENT_REQUEST_SHOW_SETUP_QR,
|
||||
eventHub,
|
||||
} from "../../common/events.ts";
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
|
||||
import { decodeAnyArray, encodeAnyArray } from "../../common/utils.ts";
|
||||
import qrcode from "qrcode-generator";
|
||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
||||
|
||||
export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsidianModule {
|
||||
$everyOnload(): Promise<boolean> {
|
||||
this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => await this.setupWizard(conf.settings));
|
||||
this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => {
|
||||
if (conf.settings) {
|
||||
await this.setupWizard(conf.settings);
|
||||
} else if (conf.settingsQR) {
|
||||
await this.decodeQR(conf.settingsQR);
|
||||
}
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-setting-qr",
|
||||
name: "Show settings as a QR code",
|
||||
callback: () => fireAndForget(this.encodeQR()),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-copysetupuri",
|
||||
@@ -35,35 +59,99 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi
|
||||
});
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => this.command_openSetupURI()));
|
||||
eventHub.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => fireAndForget(() => this.command_copySetupURI()));
|
||||
eventHub.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => fireAndForget(() => this.encodeQR()));
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async encodeQR() {
|
||||
const settingArr = [];
|
||||
const fullIndexes = Object.entries(KeyIndexOfSettings) as [keyof ObsidianLiveSyncSettings, number][];
|
||||
for (const [settingKey, index] of fullIndexes) {
|
||||
const settingValue = this.settings[settingKey];
|
||||
settingArr[index] = settingValue;
|
||||
}
|
||||
const w = encodeAnyArray(settingArr);
|
||||
// console.warn(w.length)
|
||||
// console.warn(w);
|
||||
// const j = decodeAnyArray(w);
|
||||
// console.warn(j);
|
||||
// console.warn(`is equal: ${isObjectDifferent(settingArr, j)}`);
|
||||
const qr = qrcode(0, "L");
|
||||
const uri = `${configURIBaseQR}${encodeURIComponent(w)}`;
|
||||
qr.addData(uri);
|
||||
qr.make();
|
||||
const img = qr.createSvgTag(3);
|
||||
const msg = $msg("Setup.QRCode", { qr_image: img });
|
||||
await this.core.confirm.confirmWithMessage("Settings QR Code", msg, ["OK"], "OK");
|
||||
return await Promise.resolve(w);
|
||||
}
|
||||
async decodeQR(qr: string) {
|
||||
const settingArr = decodeAnyArray(qr);
|
||||
// console.warn(settingArr);
|
||||
const fullIndexes = Object.entries(KeyIndexOfSettings) as [keyof ObsidianLiveSyncSettings, number][];
|
||||
const newSettings = { ...DEFAULT_SETTINGS } as ObsidianLiveSyncSettings;
|
||||
for (const [settingKey, index] of fullIndexes) {
|
||||
if (index >= settingArr.length) {
|
||||
// Possibly a new setting added.
|
||||
continue;
|
||||
}
|
||||
const settingValue = settingArr[index];
|
||||
//@ts-ignore
|
||||
newSettings[settingKey] = settingValue;
|
||||
}
|
||||
console.warn(newSettings);
|
||||
await this.applySettingWizard(this.settings, newSettings, "QR Code");
|
||||
}
|
||||
async command_copySetupURI(stripExtra = true) {
|
||||
const encryptingPassphrase = await this.core.confirm.askString("Encrypt your settings", "The passphrase to encrypt the setup URI", "", true);
|
||||
if (encryptingPassphrase === false)
|
||||
return;
|
||||
const setting = { ...this.settings, configPassphraseStore: "", encryptedCouchDBConnection: "", encryptedPassphrase: "" } as Partial<ObsidianLiveSyncSettings>;
|
||||
const encryptingPassphrase = await this.core.confirm.askString(
|
||||
"Encrypt your settings",
|
||||
"The passphrase to encrypt the setup URI",
|
||||
"",
|
||||
true
|
||||
);
|
||||
if (encryptingPassphrase === false) return;
|
||||
const setting = {
|
||||
...this.settings,
|
||||
configPassphraseStore: "",
|
||||
encryptedCouchDBConnection: "",
|
||||
encryptedPassphrase: "",
|
||||
} as Partial<ObsidianLiveSyncSettings>;
|
||||
if (stripExtra) {
|
||||
delete setting.pluginSyncExtendedSetting;
|
||||
}
|
||||
const keys = Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[];
|
||||
for (const k of keys) {
|
||||
if (JSON.stringify(k in setting ? setting[k] : "") == JSON.stringify(k in DEFAULT_SETTINGS ? DEFAULT_SETTINGS[k] : "*")) {
|
||||
if (
|
||||
JSON.stringify(k in setting ? setting[k] : "") ==
|
||||
JSON.stringify(k in DEFAULT_SETTINGS ? DEFAULT_SETTINGS[k] : "*")
|
||||
) {
|
||||
delete setting[k];
|
||||
}
|
||||
}
|
||||
const encryptedSetting = encodeURIComponent(await encrypt(JSON.stringify(setting), encryptingPassphrase, false));
|
||||
const uri = `${configURIBase}${encryptedSetting}`;
|
||||
const encryptedSetting = encodeURIComponent(
|
||||
await encrypt(JSON.stringify(setting), encryptingPassphrase, false)
|
||||
);
|
||||
const uri = `${configURIBase}${encryptedSetting} `;
|
||||
await navigator.clipboard.writeText(uri);
|
||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
async command_copySetupURIFull() {
|
||||
const encryptingPassphrase = await this.core.confirm.askString("Encrypt your settings", "The passphrase to encrypt the setup URI", "", true);
|
||||
if (encryptingPassphrase === false)
|
||||
return;
|
||||
const setting = { ...this.settings, configPassphraseStore: "", encryptedCouchDBConnection: "", encryptedPassphrase: "" };
|
||||
const encryptedSetting = encodeURIComponent(await encrypt(JSON.stringify(setting), encryptingPassphrase, false));
|
||||
const uri = `${configURIBase}${encryptedSetting}`;
|
||||
const encryptingPassphrase = await this.core.confirm.askString(
|
||||
"Encrypt your settings",
|
||||
"The passphrase to encrypt the setup URI",
|
||||
"",
|
||||
true
|
||||
);
|
||||
if (encryptingPassphrase === false) return;
|
||||
const setting = {
|
||||
...this.settings,
|
||||
configPassphraseStore: "",
|
||||
encryptedCouchDBConnection: "",
|
||||
encryptedPassphrase: "",
|
||||
};
|
||||
const encryptedSetting = encodeURIComponent(
|
||||
await encrypt(JSON.stringify(setting), encryptingPassphrase, false)
|
||||
);
|
||||
const uri = `${configURIBase}${encryptedSetting} `;
|
||||
await navigator.clipboard.writeText(uri);
|
||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
@@ -71,126 +159,168 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi
|
||||
await this.command_copySetupURI(false);
|
||||
}
|
||||
async command_openSetupURI() {
|
||||
const setupURI = await this.core.confirm.askString("Easy setup", "Set up URI", `${configURIBase}aaaaa`);
|
||||
if (setupURI === false)
|
||||
return;
|
||||
const setupURI = await this.core.confirm.askString("Easy setup", "Set up URI", `${configURIBase} aaaaa`);
|
||||
if (setupURI === false) return;
|
||||
if (!setupURI.startsWith(`${configURIBase}`)) {
|
||||
this._log("Set up URI looks wrong.", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const config = decodeURIComponent(setupURI.substring(configURIBase.length));
|
||||
console.dir(config);
|
||||
await this.setupWizard(config);
|
||||
}
|
||||
async applySettingWizard(
|
||||
oldConf: ObsidianLiveSyncSettings,
|
||||
newConf: ObsidianLiveSyncSettings,
|
||||
method = "Setup URI"
|
||||
) {
|
||||
const result = await this.core.confirm.askYesNoDialog(
|
||||
"Importing Configuration from the " + method + ". Are you sure to proceed ? ",
|
||||
{}
|
||||
);
|
||||
if (result == "yes") {
|
||||
const newSettingW = Object.assign({}, DEFAULT_SETTINGS, newConf) as ObsidianLiveSyncSettings;
|
||||
this.core.replicator.closeReplication();
|
||||
this.settings.suspendFileWatching = true;
|
||||
console.dir(newSettingW);
|
||||
// Back into the default method once.
|
||||
newSettingW.configPassphraseStore = "";
|
||||
newSettingW.encryptedPassphrase = "";
|
||||
newSettingW.encryptedCouchDBConnection = "";
|
||||
newSettingW.additionalSuffixOfDatabaseName = `${"appId" in this.app ? this.app.appId : ""} `;
|
||||
const setupJustImport = "Don't sync anything, just apply the settings.";
|
||||
const setupAsNew = "This is a new client - sync everything from the remote server.";
|
||||
const setupAsMerge = "This is an existing client - merge existing files with the server.";
|
||||
const setupAgain = "Initialise new server data - ideal for new or broken servers.";
|
||||
const setupManually = "Continue and configure manually.";
|
||||
newSettingW.syncInternalFiles = false;
|
||||
newSettingW.usePluginSync = false;
|
||||
newSettingW.isConfigured = true;
|
||||
// Migrate completely obsoleted configuration.
|
||||
if (!newSettingW.useIndexedDBAdapter) {
|
||||
newSettingW.useIndexedDBAdapter = true;
|
||||
}
|
||||
|
||||
const setupType = await this.core.confirm.askSelectStringDialogue(
|
||||
"How would you like to set it up?",
|
||||
[setupAsNew, setupAgain, setupAsMerge, setupJustImport, setupManually],
|
||||
{ defaultAction: setupAsNew }
|
||||
);
|
||||
if (setupType == setupJustImport) {
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.saveSettings();
|
||||
} else if (setupType == setupAsNew) {
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.saveSettings();
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
} else if (setupType == setupAsMerge) {
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.saveSettings();
|
||||
await this.core.rebuilder.$fetchLocal(true);
|
||||
} else if (setupType == setupAgain) {
|
||||
const confirm =
|
||||
"This operation will rebuild all databases with files on this device. Any files on the remote database not synced here will be lost.";
|
||||
if (
|
||||
(await this.core.confirm.askSelectStringDialogue(
|
||||
"Are you sure you want to do this?",
|
||||
["Cancel", confirm],
|
||||
{ defaultAction: "Cancel" }
|
||||
)) != confirm
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.core.settings = newSettingW;
|
||||
await this.core.saveSettings();
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.rebuilder.$rebuildEverything();
|
||||
} else if (setupType == setupManually) {
|
||||
const keepLocalDB = await this.core.confirm.askYesNoDialog("Keep local DB?", {
|
||||
defaultOption: "No",
|
||||
});
|
||||
const keepRemoteDB = await this.core.confirm.askYesNoDialog("Keep remote DB?", {
|
||||
defaultOption: "No",
|
||||
});
|
||||
if (keepLocalDB == "yes" && keepRemoteDB == "yes") {
|
||||
// nothing to do. so peaceful.
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.$allSuspendAllSync();
|
||||
await this.core.$allSuspendExtraSync();
|
||||
await this.core.saveSettings();
|
||||
const replicate = await this.core.confirm.askYesNoDialog("Unlock and replicate?", {
|
||||
defaultOption: "Yes",
|
||||
});
|
||||
if (replicate == "yes") {
|
||||
await this.core.$$replicate(true);
|
||||
await this.core.$$markRemoteUnlocked();
|
||||
}
|
||||
this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
if (keepLocalDB == "no" && keepRemoteDB == "no") {
|
||||
const reset = await this.core.confirm.askYesNoDialog("Drop everything?", {
|
||||
defaultOption: "No",
|
||||
});
|
||||
if (reset != "yes") {
|
||||
this._log("Cancelled", LOG_LEVEL_NOTICE);
|
||||
this.core.settings = oldConf;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let initDB;
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.saveSettings();
|
||||
if (keepLocalDB == "no") {
|
||||
await this.core.$$resetLocalDatabase();
|
||||
await this.core.localDatabase.initializeDatabase();
|
||||
const rebuild = await this.core.confirm.askYesNoDialog("Rebuild the database?", {
|
||||
defaultOption: "Yes",
|
||||
});
|
||||
if (rebuild == "yes") {
|
||||
initDB = this.core.$$initializeDatabase(true);
|
||||
} else {
|
||||
await this.core.$$markRemoteResolved();
|
||||
}
|
||||
}
|
||||
if (keepRemoteDB == "no") {
|
||||
await this.core.$$tryResetRemoteDatabase();
|
||||
await this.core.$$markRemoteLocked();
|
||||
}
|
||||
if (keepLocalDB == "no" || keepRemoteDB == "no") {
|
||||
const replicate = await this.core.confirm.askYesNoDialog("Replicate once?", {
|
||||
defaultOption: "Yes",
|
||||
});
|
||||
if (replicate == "yes") {
|
||||
if (initDB != null) {
|
||||
await initDB;
|
||||
}
|
||||
await this.core.$$replicate(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this._log("Cancelled", LOG_LEVEL_NOTICE);
|
||||
this.core.settings = oldConf;
|
||||
return;
|
||||
}
|
||||
}
|
||||
async setupWizard(confString: string) {
|
||||
try {
|
||||
const oldConf = JSON.parse(JSON.stringify(this.settings));
|
||||
const encryptingPassphrase = await this.core.confirm.askString("Passphrase", "The passphrase to decrypt your setup URI", "", true);
|
||||
if (encryptingPassphrase === false)
|
||||
return;
|
||||
const encryptingPassphrase = await this.core.confirm.askString(
|
||||
"Passphrase",
|
||||
"The passphrase to decrypt your setup URI",
|
||||
"",
|
||||
true
|
||||
);
|
||||
if (encryptingPassphrase === false) return;
|
||||
const newConf = await JSON.parse(await decrypt(confString, encryptingPassphrase, false));
|
||||
if (newConf) {
|
||||
const result = await this.core.confirm.askYesNoDialog("Importing Configuration from the Setup-URI. Are you sure to proceed?", {});
|
||||
if (result == "yes") {
|
||||
const newSettingW = Object.assign({}, DEFAULT_SETTINGS, newConf) as ObsidianLiveSyncSettings;
|
||||
this.core.replicator.closeReplication();
|
||||
this.settings.suspendFileWatching = true;
|
||||
console.dir(newSettingW);
|
||||
// Back into the default method once.
|
||||
newSettingW.configPassphraseStore = "";
|
||||
newSettingW.encryptedPassphrase = "";
|
||||
newSettingW.encryptedCouchDBConnection = "";
|
||||
newSettingW.additionalSuffixOfDatabaseName = `${("appId" in this.app ? this.app.appId : "")}`
|
||||
const setupJustImport = "Just import setting";
|
||||
const setupAsNew = "Set it up as secondary or subsequent device";
|
||||
const setupAsMerge = "Secondary device but try keeping local changes";
|
||||
const setupAgain = "Reconfigure and reconstitute the data";
|
||||
const setupManually = "Leave everything to me";
|
||||
newSettingW.syncInternalFiles = false;
|
||||
newSettingW.usePluginSync = false;
|
||||
newSettingW.isConfigured = true;
|
||||
// Migrate completely obsoleted configuration.
|
||||
if (!newSettingW.useIndexedDBAdapter) {
|
||||
newSettingW.useIndexedDBAdapter = true;
|
||||
}
|
||||
|
||||
const setupType = await this.core.confirm.askSelectStringDialogue("How would you like to set it up?", [setupAsNew, setupAgain, setupAsMerge, setupJustImport, setupManually], { defaultAction: setupAsNew });
|
||||
if (setupType == setupJustImport) {
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.saveSettings();
|
||||
} else if (setupType == setupAsNew) {
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
} else if (setupType == setupAsMerge) {
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.rebuilder.$fetchLocal(true);
|
||||
} else if (setupType == setupAgain) {
|
||||
const confirm = "I know this operation will rebuild all my databases with files on this device, and files that are on the remote database and I didn't synchronize to any other devices will be lost and want to proceed indeed.";
|
||||
if (await this.core.confirm.askSelectStringDialogue("Do you really want to do this?", ["Cancel", confirm], { defaultAction: "Cancel" }) != confirm) {
|
||||
return;
|
||||
}
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.rebuilder.$rebuildEverything();
|
||||
} else if (setupType == setupManually) {
|
||||
const keepLocalDB = await this.core.confirm.askYesNoDialog("Keep local DB?", { defaultOption: "No" });
|
||||
const keepRemoteDB = await this.core.confirm.askYesNoDialog("Keep remote DB?", { defaultOption: "No" });
|
||||
if (keepLocalDB == "yes" && keepRemoteDB == "yes") {
|
||||
// nothing to do. so peaceful.
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.$allSuspendAllSync();
|
||||
await this.core.$allSuspendExtraSync();
|
||||
await this.core.saveSettings();
|
||||
const replicate = await this.core.confirm.askYesNoDialog("Unlock and replicate?", { defaultOption: "Yes" });
|
||||
if (replicate == "yes") {
|
||||
await this.core.$$replicate(true);
|
||||
await this.core.$$markRemoteUnlocked();
|
||||
}
|
||||
this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
if (keepLocalDB == "no" && keepRemoteDB == "no") {
|
||||
const reset = await this.core.confirm.askYesNoDialog("Drop everything?", { defaultOption: "No" });
|
||||
if (reset != "yes") {
|
||||
this._log("Cancelled", LOG_LEVEL_NOTICE);
|
||||
this.core.settings = oldConf;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let initDB;
|
||||
this.core.settings = newSettingW;
|
||||
this.core.$$clearUsedPassphrase();
|
||||
await this.core.saveSettings();
|
||||
if (keepLocalDB == "no") {
|
||||
await this.core.$$resetLocalDatabase();
|
||||
await this.core.localDatabase.initializeDatabase();
|
||||
const rebuild = await this.core.confirm.askYesNoDialog("Rebuild the database?", { defaultOption: "Yes" });
|
||||
if (rebuild == "yes") {
|
||||
initDB = this.core.$$initializeDatabase(true);
|
||||
} else {
|
||||
await this.core.$$markRemoteResolved();
|
||||
}
|
||||
}
|
||||
if (keepRemoteDB == "no") {
|
||||
await this.core.$$tryResetRemoteDatabase();
|
||||
await this.core.$$markRemoteLocked();
|
||||
}
|
||||
if (keepLocalDB == "no" || keepRemoteDB == "no") {
|
||||
const replicate = await this.core.confirm.askYesNoDialog("Replicate once?", { defaultOption: "Yes" });
|
||||
if (replicate == "yes") {
|
||||
if (initDB != null) {
|
||||
await initDB;
|
||||
}
|
||||
await this.core.$$replicate(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.applySettingWizard(oldConf, newConf);
|
||||
this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this._log("Cancelled.", LOG_LEVEL_NOTICE);
|
||||
@@ -200,6 +330,4 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import { Setting, TextComponent, type ToggleComponent, type DropdownComponent, ButtonComponent, type TextAreaComponent, type ValueComponent } from "obsidian";
|
||||
import {
|
||||
Setting,
|
||||
TextComponent,
|
||||
type ToggleComponent,
|
||||
type DropdownComponent,
|
||||
ButtonComponent,
|
||||
type TextAreaComponent,
|
||||
type ValueComponent,
|
||||
} from "obsidian";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { LEVEL_ADVANCED, LEVEL_POWER_USER, statusDisplay, type ConfigurationItem } from "../../../lib/src/common/types.ts";
|
||||
import { type ObsidianLiveSyncSettingTab, type AutoWireOption, wrapMemo, type OnUpdateResult, createStub, findAttrFromParent } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import { type AllSettingItemKey, getConfig, type AllSettings, type AllStringItemKey, type AllNumericItemKey, type AllBooleanItemKey } from "./settingConstants.ts";
|
||||
|
||||
import {
|
||||
LEVEL_ADVANCED,
|
||||
LEVEL_POWER_USER,
|
||||
statusDisplay,
|
||||
type ConfigurationItem,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import {
|
||||
type ObsidianLiveSyncSettingTab,
|
||||
type AutoWireOption,
|
||||
wrapMemo,
|
||||
type OnUpdateResult,
|
||||
createStub,
|
||||
findAttrFromParent,
|
||||
} from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import {
|
||||
type AllSettingItemKey,
|
||||
getConfig,
|
||||
type AllSettings,
|
||||
type AllStringItemKey,
|
||||
type AllNumericItemKey,
|
||||
type AllBooleanItemKey,
|
||||
} from "./settingConstants.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
|
||||
export class LiveSyncSetting extends Setting {
|
||||
autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent;
|
||||
@@ -29,8 +56,9 @@ export class LiveSyncSetting extends Setting {
|
||||
DEV: {
|
||||
const paneName = findAttrFromParent(this.settingEl, "data-pane");
|
||||
const panelName = findAttrFromParent(this.settingEl, "data-panel");
|
||||
const itemName = typeof this.nameBuf == "string" ? this.nameBuf : this.nameBuf.textContent?.toString() ?? "";
|
||||
const strValue = typeof value == "string" ? value : value.textContent?.toString() ?? "";
|
||||
const itemName =
|
||||
typeof this.nameBuf == "string" ? this.nameBuf : (this.nameBuf.textContent?.toString() ?? "");
|
||||
const strValue = typeof value == "string" ? value : (value.textContent?.toString() ?? "");
|
||||
|
||||
createStub(itemName, key, strValue, panelName, paneName);
|
||||
}
|
||||
@@ -59,7 +87,7 @@ export class LiveSyncSetting extends Setting {
|
||||
autoWireSetting(key: AllSettingItemKey, opt?: AutoWireOption) {
|
||||
const conf = getConfig(key);
|
||||
if (!conf) {
|
||||
// throw new Error(`No such setting item :${key}`)
|
||||
// throw new Error($msg("liveSyncSetting.errorNoSuchSettingItem", { key }));
|
||||
return;
|
||||
}
|
||||
const name = `${conf.name}${statusDisplay(conf.status)}`;
|
||||
@@ -111,9 +139,11 @@ export class LiveSyncSetting extends Setting {
|
||||
}
|
||||
autoWireText(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addText(text => {
|
||||
this.addText((text) => {
|
||||
this.autoWiredComponent = text;
|
||||
const setValue = wrapMemo((value: string) => { text.setValue(value) });
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
text.setValue(value);
|
||||
});
|
||||
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
||||
this.invalidateValue();
|
||||
text.onChange(async (value) => {
|
||||
@@ -129,9 +159,11 @@ export class LiveSyncSetting extends Setting {
|
||||
}
|
||||
autoWireTextArea(key: AllStringItemKey, opt?: AutoWireOption) {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addTextArea(text => {
|
||||
this.addTextArea((text) => {
|
||||
this.autoWiredComponent = text;
|
||||
const setValue = wrapMemo((value: string) => { text.setValue(value) });
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
text.setValue(value);
|
||||
});
|
||||
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
||||
this.invalidateValue();
|
||||
text.onChange(async (value) => {
|
||||
@@ -145,9 +177,12 @@ export class LiveSyncSetting extends Setting {
|
||||
});
|
||||
return this;
|
||||
}
|
||||
autoWireNumeric(key: AllNumericItemKey, opt: AutoWireOption & { clampMin?: number; clampMax?: number; acceptZero?: boolean; }) {
|
||||
autoWireNumeric(
|
||||
key: AllNumericItemKey,
|
||||
opt: AutoWireOption & { clampMin?: number; clampMax?: number; acceptZero?: boolean }
|
||||
) {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addText(text => {
|
||||
this.addText((text) => {
|
||||
this.autoWiredComponent = text;
|
||||
if (opt.clampMin) {
|
||||
text.inputEl.setAttribute("min", `${opt.clampMin}`);
|
||||
@@ -156,7 +191,9 @@ export class LiveSyncSetting extends Setting {
|
||||
text.inputEl.setAttribute("max", `${opt.clampMax}`);
|
||||
}
|
||||
let lastError = false;
|
||||
const setValue = wrapMemo((value: string) => { text.setValue(value) });
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
text.setValue(value);
|
||||
});
|
||||
this.invalidateValue = () => {
|
||||
if (!lastError) setValue(`${LiveSyncSetting.env.editingSettings[key]}`);
|
||||
};
|
||||
@@ -180,7 +217,12 @@ export class LiveSyncSetting extends Setting {
|
||||
text.inputEl.toggleClass("sls-item-invalid-value", false);
|
||||
await this.commitValue(value);
|
||||
} else {
|
||||
this.setTooltip(`The value should ${opt.clampMin || "~"} < value < ${opt.clampMax || "~"}`);
|
||||
this.setTooltip(
|
||||
$msg("liveSyncSetting.valueShouldBeInRange", {
|
||||
min: opt.clampMin?.toString() || "~",
|
||||
max: opt.clampMax?.toString() || "~",
|
||||
})
|
||||
);
|
||||
text.inputEl.toggleClass("sls-item-invalid-value", true);
|
||||
lastError = true;
|
||||
return false;
|
||||
@@ -193,9 +235,11 @@ export class LiveSyncSetting extends Setting {
|
||||
}
|
||||
autoWireToggle(key: AllBooleanItemKey, opt?: AutoWireOption) {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addToggle(toggle => {
|
||||
this.addToggle((toggle) => {
|
||||
this.autoWiredComponent = toggle;
|
||||
const setValue = wrapMemo((value: boolean) => { toggle.setValue(opt?.invert ? !value : value) });
|
||||
const setValue = wrapMemo((value: boolean) => {
|
||||
toggle.setValue(opt?.invert ? !value : value);
|
||||
});
|
||||
this.invalidateValue = () => setValue(LiveSyncSetting.env.editingSettings[key] ?? false);
|
||||
this.invalidateValue();
|
||||
|
||||
@@ -207,16 +251,15 @@ export class LiveSyncSetting extends Setting {
|
||||
});
|
||||
return this;
|
||||
}
|
||||
autoWireDropDown<T extends string>(key: AllStringItemKey, opt: AutoWireOption & { options: Record<T, string>; }) {
|
||||
autoWireDropDown<T extends string>(key: AllStringItemKey, opt: AutoWireOption & { options: Record<T, string> }) {
|
||||
const conf = this.autoWireSetting(key, opt);
|
||||
this.addDropdown(dropdown => {
|
||||
this.addDropdown((dropdown) => {
|
||||
this.autoWiredComponent = dropdown;
|
||||
const setValue = wrapMemo((value: string) => {
|
||||
dropdown.setValue(value);
|
||||
});
|
||||
|
||||
dropdown
|
||||
.addOptions(opt.options);
|
||||
dropdown.addOptions(opt.options);
|
||||
|
||||
this.invalidateValue = () => setValue(LiveSyncSetting.env.editingSettings[key] || "");
|
||||
this.invalidateValue();
|
||||
@@ -231,7 +274,7 @@ export class LiveSyncSetting extends Setting {
|
||||
this.addButton((button) => {
|
||||
this.applyButtonComponent = button;
|
||||
this.watchDirtyKeys = unique([...keys, ...this.watchDirtyKeys]);
|
||||
button.setButtonText(text ?? "Apply");
|
||||
button.setButtonText(text ?? $msg("liveSyncSettings.btnApply"));
|
||||
button.onClick(async () => {
|
||||
await LiveSyncSetting.env.saveSettings(keys);
|
||||
LiveSyncSetting.env.reloadAllSettings();
|
||||
@@ -264,7 +307,6 @@ export class LiveSyncSetting extends Setting {
|
||||
const newConf = this._getComputedStatus();
|
||||
const keys = Object.keys(newConf) as [keyof OnUpdateResult];
|
||||
for (const k of keys) {
|
||||
|
||||
if (k in this.prevStatus && this.prevStatus[k] == newConf[k]) {
|
||||
continue;
|
||||
}
|
||||
@@ -277,8 +319,8 @@ export class LiveSyncSetting extends Setting {
|
||||
case "classes":
|
||||
break;
|
||||
case "disabled":
|
||||
this.setDisabled((newConf[k] || false));
|
||||
this.settingEl.toggleClass("sls-setting-disabled", (newConf[k] || false));
|
||||
this.setDisabled(newConf[k] || false);
|
||||
this.settingEl.toggleClass("sls-setting-disabled", newConf[k] || false);
|
||||
this.prevStatus[k] = newConf[k];
|
||||
break;
|
||||
case "isCta":
|
||||
@@ -327,7 +369,11 @@ export class LiveSyncSetting extends Setting {
|
||||
}
|
||||
if (this.holdValue && this.selfKey) {
|
||||
const isDirty = LiveSyncSetting.env.isDirty(this.selfKey);
|
||||
const alt = isDirty ? `Original: ${LiveSyncSetting.env.initialSettings![this.selfKey]}` : "";
|
||||
const alt = isDirty
|
||||
? $msg("liveSyncSetting.originalValue", {
|
||||
value: String(LiveSyncSetting.env.initialSettings?.[this.selfKey] ?? ""),
|
||||
})
|
||||
: "";
|
||||
this.controlEl.toggleClass("sls-item-dirty", isDirty);
|
||||
if (!this.hasPassword) {
|
||||
this.nameEl.toggleClass("sls-item-dirty-help", isDirty);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,21 @@
|
||||
import { $t } from "../../../lib/src/common/i18n.ts";
|
||||
import { DEFAULT_SETTINGS, configurationNames, type ConfigurationItem, type FilterBooleanKeys, type FilterNumberKeys, type FilterStringKeys, type ObsidianLiveSyncSettings } from "../../../lib/src/common/types.ts";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
configurationNames,
|
||||
type ConfigurationItem,
|
||||
type FilterBooleanKeys,
|
||||
type FilterNumberKeys,
|
||||
type FilterStringKeys,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
|
||||
export type OnDialogSettings = {
|
||||
configPassphrase: string,
|
||||
preset: "" | "PERIODIC" | "LIVESYNC" | "DISABLE",
|
||||
syncMode: "ONEVENTS" | "PERIODIC" | "LIVESYNC"
|
||||
dummy: number,
|
||||
deviceAndVaultName: string,
|
||||
}
|
||||
configPassphrase: string;
|
||||
preset: "" | "PERIODIC" | "LIVESYNC" | "DISABLE";
|
||||
syncMode: "ONEVENTS" | "PERIODIC" | "LIVESYNC";
|
||||
dummy: number;
|
||||
deviceAndVaultName: string;
|
||||
};
|
||||
|
||||
export const OnDialogSettingsDefault: OnDialogSettings = {
|
||||
configPassphrase: "",
|
||||
@@ -15,9 +23,8 @@ export const OnDialogSettingsDefault: OnDialogSettings = {
|
||||
syncMode: "ONEVENTS",
|
||||
dummy: 0,
|
||||
deviceAndVaultName: "",
|
||||
}
|
||||
export const AllSettingDefault =
|
||||
{ ...DEFAULT_SETTINGS, ...OnDialogSettingsDefault }
|
||||
};
|
||||
export const AllSettingDefault = { ...DEFAULT_SETTINGS, ...OnDialogSettingsDefault };
|
||||
|
||||
export type AllSettings = ObsidianLiveSyncSettings & OnDialogSettings;
|
||||
export type AllStringItemKey = FilterStringKeys<AllSettings>;
|
||||
@@ -25,340 +32,352 @@ export type AllNumericItemKey = FilterNumberKeys<AllSettings>;
|
||||
export type AllBooleanItemKey = FilterBooleanKeys<AllSettings>;
|
||||
export type AllSettingItemKey = AllStringItemKey | AllNumericItemKey | AllBooleanItemKey;
|
||||
|
||||
export type ValueOf<T extends AllSettingItemKey> =
|
||||
T extends AllStringItemKey ? string :
|
||||
T extends AllNumericItemKey ? number :
|
||||
T extends AllBooleanItemKey ? boolean :
|
||||
AllSettings[T];
|
||||
export type ValueOf<T extends AllSettingItemKey> = T extends AllStringItemKey
|
||||
? string
|
||||
: T extends AllNumericItemKey
|
||||
? number
|
||||
: T extends AllBooleanItemKey
|
||||
? boolean
|
||||
: AllSettings[T];
|
||||
|
||||
export const SettingInformation: Partial<Record<keyof AllSettings, ConfigurationItem>> = {
|
||||
"liveSync": {
|
||||
"name": "Sync Mode"
|
||||
},
|
||||
"couchDB_URI": {
|
||||
"name": "URI",
|
||||
"placeHolder": "https://........"
|
||||
},
|
||||
"couchDB_USER": {
|
||||
"name": "Username",
|
||||
"desc": "username"
|
||||
},
|
||||
"couchDB_PASSWORD": {
|
||||
"name": "Password",
|
||||
"desc": "password"
|
||||
},
|
||||
"couchDB_DBNAME": {
|
||||
"name": "Database name"
|
||||
},
|
||||
"passphrase": {
|
||||
"name": "Passphrase",
|
||||
"desc": "Encrypting passphrase. If you change the passphrase of an existing database, overwriting the remote database is strongly recommended."
|
||||
},
|
||||
"showStatusOnEditor": {
|
||||
"name": "Show status inside the editor",
|
||||
"desc": "Reflected after reboot"
|
||||
},
|
||||
"showOnlyIconsOnEditor": {
|
||||
"name": "Show status as icons only"
|
||||
},
|
||||
"showStatusOnStatusbar": {
|
||||
"name": "Show status on the status bar",
|
||||
"desc": "Reflected after reboot."
|
||||
},
|
||||
"lessInformationInLog": {
|
||||
"name": "Show only notifications",
|
||||
"desc": "Prevent logging and show only notification. Please disable when you report the logs"
|
||||
},
|
||||
"showVerboseLog": {
|
||||
"name": "Verbose Log",
|
||||
"desc": "Show verbose log. Please enable when you report the logs"
|
||||
},
|
||||
"hashCacheMaxCount": {
|
||||
"name": "Memory cache size (by total items)"
|
||||
},
|
||||
"hashCacheMaxAmount": {
|
||||
"name": "Memory cache size (by total characters)",
|
||||
"desc": "(Mega chars)"
|
||||
},
|
||||
"writeCredentialsForSettingSync": {
|
||||
"name": "Write credentials in the file",
|
||||
"desc": "(Not recommended) If set, credentials will be stored in the file."
|
||||
},
|
||||
"notifyAllSettingSyncFile": {
|
||||
"name": "Notify all setting files"
|
||||
},
|
||||
"configPassphrase": {
|
||||
"name": "Passphrase of sensitive configuration items",
|
||||
"desc": "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again."
|
||||
},
|
||||
"configPassphraseStore": {
|
||||
"name": "Encrypting sensitive configuration items"
|
||||
},
|
||||
"syncOnSave": {
|
||||
"name": "Sync on Save",
|
||||
"desc": "When you save a file, sync automatically"
|
||||
},
|
||||
"syncOnEditorSave": {
|
||||
"name": "Sync on Editor Save",
|
||||
"desc": "When you save a file in the editor, sync automatically"
|
||||
},
|
||||
"syncOnFileOpen": {
|
||||
"name": "Sync on File Open",
|
||||
"desc": "When you open a file, sync automatically"
|
||||
},
|
||||
"syncOnStart": {
|
||||
"name": "Sync on Start",
|
||||
"desc": "Start synchronization after launching Obsidian."
|
||||
},
|
||||
"syncAfterMerge": {
|
||||
"name": "Sync after merging file",
|
||||
"desc": "Sync automatically after merging files"
|
||||
},
|
||||
"trashInsteadDelete": {
|
||||
"name": "Use the trash bin",
|
||||
"desc": "Do not delete files that are deleted in remote, just move to trash."
|
||||
},
|
||||
"doNotDeleteFolder": {
|
||||
"name": "Keep empty folder",
|
||||
"desc": "Normally, a folder is deleted when it becomes empty after a synchronization. Enabling this will prevent it from getting deleted"
|
||||
},
|
||||
"resolveConflictsByNewerFile": {
|
||||
"name": "Always overwrite with a newer file (beta)",
|
||||
"desc": "(Def off) Resolve conflicts by newer files automatically."
|
||||
},
|
||||
"checkConflictOnlyOnOpen": {
|
||||
"name": "Postpone resolution of inactive files"
|
||||
},
|
||||
"showMergeDialogOnlyOnActive": {
|
||||
"name": "Postpone manual resolution of inactive files"
|
||||
},
|
||||
"disableMarkdownAutoMerge": {
|
||||
"name": "Always resolve conflicts manually",
|
||||
"desc": "If this switch is turned on, a merge dialog will be displayed, even if the sensible-merge is possible automatically. (Turn on to previous behavior)"
|
||||
},
|
||||
"writeDocumentsIfConflicted": {
|
||||
"name": "Always reflect synchronized changes even if the note has a conflict",
|
||||
"desc": "Turn on to previous behavior"
|
||||
},
|
||||
"syncInternalFilesInterval": {
|
||||
"name": "Scan hidden files periodically",
|
||||
"desc": "Seconds, 0 to disable"
|
||||
},
|
||||
"batchSave": {
|
||||
"name": "Batch database update",
|
||||
"desc": "Reducing the frequency with which on-disk changes are reflected into the DB"
|
||||
},
|
||||
"readChunksOnline": {
|
||||
"name": "Fetch chunks on demand",
|
||||
"desc": "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended."
|
||||
},
|
||||
"syncMaxSizeInMB": {
|
||||
"name": "Maximum file size",
|
||||
"desc": "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used."
|
||||
},
|
||||
"useIgnoreFiles": {
|
||||
"name": "(Beta) Use ignore files",
|
||||
"desc": "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files."
|
||||
},
|
||||
"ignoreFiles": {
|
||||
"name": "Ignore files",
|
||||
"desc": "We can use multiple ignore files, e.g.) `.gitignore, .dockerignore`"
|
||||
},
|
||||
"batch_size": {
|
||||
"name": "Batch size",
|
||||
"desc": "Number of change feed items to process at a time. Defaults to 50. Minimum is 2."
|
||||
},
|
||||
"batches_limit": {
|
||||
"name": "Batch limit",
|
||||
"desc": "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time."
|
||||
},
|
||||
"useTimeouts": {
|
||||
"name": "Use timeouts instead of heartbeats",
|
||||
"desc": "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage."
|
||||
},
|
||||
"concurrencyOfReadChunksOnline": {
|
||||
"name": "Batch size of on-demand fetching"
|
||||
},
|
||||
"minimumIntervalOfReadChunksOnline": {
|
||||
"name": "The delay for consecutive on-demand fetches"
|
||||
},
|
||||
"suspendFileWatching": {
|
||||
"name": "Suspend file watching",
|
||||
"desc": "Stop watching for file change."
|
||||
},
|
||||
"suspendParseReplicationResult": {
|
||||
"name": "Suspend database reflecting",
|
||||
"desc": "Stop reflecting database changes to storage files."
|
||||
},
|
||||
"writeLogToTheFile": {
|
||||
"name": "Write logs into the file",
|
||||
"desc": "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information."
|
||||
},
|
||||
"deleteMetadataOfDeletedFiles": {
|
||||
"name": "Do not keep metadata of deleted files."
|
||||
},
|
||||
"useIndexedDBAdapter": {
|
||||
"name": "(Obsolete) Use an old adapter for compatibility",
|
||||
"desc": "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.",
|
||||
"obsolete": true
|
||||
},
|
||||
"watchInternalFileChanges": {
|
||||
"name": "Scan changes on customization sync",
|
||||
"desc": "Do not use internal API"
|
||||
},
|
||||
"doNotSuspendOnFetching": {
|
||||
"name": "Fetch database with previous behaviour"
|
||||
},
|
||||
"disableCheckingConfigMismatch": {
|
||||
"name": "Do not check configuration mismatch before replication"
|
||||
},
|
||||
"usePluginSync": {
|
||||
"name": "Enable customization sync"
|
||||
},
|
||||
"autoSweepPlugins": {
|
||||
"name": "Scan customization automatically",
|
||||
"desc": "Scan customization before replicating."
|
||||
},
|
||||
"autoSweepPluginsPeriodic": {
|
||||
"name": "Scan customization periodically",
|
||||
"desc": "Scan customization every 1 minute."
|
||||
},
|
||||
"notifyPluginOrSettingUpdated": {
|
||||
"name": "Notify customized",
|
||||
"desc": "Notify when other device has newly customized."
|
||||
},
|
||||
"remoteType": {
|
||||
"name": "Remote Type",
|
||||
"desc": "Remote server type"
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "Endpoint URL",
|
||||
"placeHolder": "https://........"
|
||||
},
|
||||
"accessKey": {
|
||||
"name": "Access Key"
|
||||
},
|
||||
"secretKey": {
|
||||
"name": "Secret Key"
|
||||
},
|
||||
"region": {
|
||||
"name": "Region",
|
||||
"placeHolder": "auto"
|
||||
},
|
||||
"bucket": {
|
||||
"name": "Bucket Name"
|
||||
},
|
||||
"useCustomRequestHandler": {
|
||||
"name": "Use Custom HTTP Handler",
|
||||
"desc": "If your Object Storage could not configured accepting CORS, enable this."
|
||||
},
|
||||
"maxChunksInEden": {
|
||||
"name": "Maximum Incubating Chunks",
|
||||
"desc": "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks."
|
||||
},
|
||||
"maxTotalLengthInEden": {
|
||||
"name": "Maximum Incubating Chunk Size",
|
||||
"desc": "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks."
|
||||
},
|
||||
"maxAgeInEden": {
|
||||
"name": "Maximum Incubation Period",
|
||||
"desc": "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks."
|
||||
},
|
||||
"settingSyncFile": {
|
||||
"name": "Filename",
|
||||
"desc": "If you set this, all settings are saved in a markdown file. You will be notified when new settings arrive. You can set different files by the platform."
|
||||
},
|
||||
"preset": {
|
||||
"name": "Presets",
|
||||
"desc": "Apply preset configuration"
|
||||
},
|
||||
"syncMode": {
|
||||
liveSync: {
|
||||
name: "Sync Mode",
|
||||
},
|
||||
"periodicReplicationInterval": {
|
||||
"name": "Periodic Sync interval",
|
||||
"desc": "Interval (sec)"
|
||||
couchDB_URI: {
|
||||
name: "Server URI",
|
||||
placeHolder: "https://........",
|
||||
},
|
||||
"syncInternalFilesBeforeReplication": {
|
||||
"name": "Scan for hidden files before replication"
|
||||
couchDB_USER: {
|
||||
name: "Username",
|
||||
desc: "username",
|
||||
},
|
||||
"automaticallyDeleteMetadataOfDeletedFiles": {
|
||||
"name": "Delete old metadata of deleted files on start-up",
|
||||
"desc": "(Days passed, 0 to disable automatic-deletion)"
|
||||
couchDB_PASSWORD: {
|
||||
name: "Password",
|
||||
desc: "password",
|
||||
},
|
||||
"additionalSuffixOfDatabaseName": {
|
||||
"name": "Database suffix",
|
||||
"desc": "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured."
|
||||
couchDB_DBNAME: {
|
||||
name: "Database Name",
|
||||
},
|
||||
"hashAlg": {
|
||||
"name": configurationNames["hashAlg"]?.name || "",
|
||||
"desc": "xxhash64 is the current default."
|
||||
passphrase: {
|
||||
name: "Passphrase",
|
||||
desc: "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.",
|
||||
},
|
||||
"deviceAndVaultName": {
|
||||
"name": "Device name",
|
||||
"desc": "Unique name between all synchronized devices. To edit this setting, please disable customization sync once."
|
||||
showStatusOnEditor: {
|
||||
name: "Show status inside the editor",
|
||||
desc: "Requires restart of Obsidian.",
|
||||
},
|
||||
"displayLanguage": {
|
||||
"name": "Display Language",
|
||||
"desc": "Not all messages have been translated. And, please revert to \"Default\" when reporting errors."
|
||||
showOnlyIconsOnEditor: {
|
||||
name: "Show status as icons only",
|
||||
},
|
||||
showStatusOnStatusbar: {
|
||||
name: "Show status on the status bar",
|
||||
desc: "Requires restart of Obsidian.",
|
||||
},
|
||||
lessInformationInLog: {
|
||||
name: "Show only notifications",
|
||||
desc: "Disables logging, only shows notifications. Please disable if you report an issue.",
|
||||
},
|
||||
showVerboseLog: {
|
||||
name: "Verbose Log",
|
||||
desc: "Show verbose log. Please enable if you report an issue.",
|
||||
},
|
||||
hashCacheMaxCount: {
|
||||
name: "Memory cache size (by total items)",
|
||||
},
|
||||
hashCacheMaxAmount: {
|
||||
name: "Memory cache size (by total characters)",
|
||||
desc: "(Mega chars)",
|
||||
},
|
||||
writeCredentialsForSettingSync: {
|
||||
name: "Write credentials in the file",
|
||||
desc: "(Not recommended) If set, credentials will be stored in the file.",
|
||||
},
|
||||
notifyAllSettingSyncFile: {
|
||||
name: "Notify all setting files",
|
||||
},
|
||||
configPassphrase: {
|
||||
name: "Passphrase of sensitive configuration items",
|
||||
desc: "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.",
|
||||
},
|
||||
configPassphraseStore: {
|
||||
name: "Encrypting sensitive configuration items",
|
||||
},
|
||||
syncOnSave: {
|
||||
name: "Sync on Save",
|
||||
desc: "Starts synchronisation when a file is saved.",
|
||||
},
|
||||
syncOnEditorSave: {
|
||||
name: "Sync on Editor Save",
|
||||
desc: "When you save a file in the editor, start a sync automatically",
|
||||
},
|
||||
syncOnFileOpen: {
|
||||
name: "Sync on File Open",
|
||||
desc: "Forces the file to be synced when opened.",
|
||||
},
|
||||
syncOnStart: {
|
||||
name: "Sync on Startup",
|
||||
desc: "Automatically Sync all files when opening Obsidian.",
|
||||
},
|
||||
syncAfterMerge: {
|
||||
name: "Sync after merging file",
|
||||
desc: "Sync automatically after merging files",
|
||||
},
|
||||
trashInsteadDelete: {
|
||||
name: "Use the trash bin",
|
||||
desc: "Move remotely deleted files to the trash, instead of deleting.",
|
||||
},
|
||||
doNotDeleteFolder: {
|
||||
name: "Keep empty folder",
|
||||
desc: "Should we keep folders that don't have any files inside?",
|
||||
},
|
||||
resolveConflictsByNewerFile: {
|
||||
name: "(BETA) Always overwrite with a newer file",
|
||||
desc: "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.",
|
||||
},
|
||||
checkConflictOnlyOnOpen: {
|
||||
name: "Delay conflict resolution of inactive files",
|
||||
desc: "Should we only check for conflicts when a file is opened?",
|
||||
},
|
||||
showMergeDialogOnlyOnActive: {
|
||||
name: "Delay merge conflict prompt for inactive files.",
|
||||
desc: "Should we prompt you about conflicting files when a file is opened?",
|
||||
},
|
||||
disableMarkdownAutoMerge: {
|
||||
name: "Always prompt merge conflicts",
|
||||
desc: "Should we prompt you for every single merge, even if we can safely merge automatcially?",
|
||||
},
|
||||
writeDocumentsIfConflicted: {
|
||||
name: "Apply Latest Change if Conflicting",
|
||||
desc: "Enable this option to automatically apply the most recent change to documents even when it conflicts",
|
||||
},
|
||||
syncInternalFilesInterval: {
|
||||
name: "Scan hidden files periodically",
|
||||
desc: "Seconds, 0 to disable",
|
||||
},
|
||||
batchSave: {
|
||||
name: "Batch database update",
|
||||
desc: "Reducing the frequency with which on-disk changes are reflected into the DB",
|
||||
},
|
||||
readChunksOnline: {
|
||||
name: "Fetch chunks on demand",
|
||||
desc: "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.",
|
||||
},
|
||||
syncMaxSizeInMB: {
|
||||
name: "Maximum file size",
|
||||
desc: "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.",
|
||||
},
|
||||
useIgnoreFiles: {
|
||||
name: "(Beta) Use ignore files",
|
||||
desc: "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.",
|
||||
},
|
||||
ignoreFiles: {
|
||||
name: "Ignore files",
|
||||
desc: "Comma separated `.gitignore, .dockerignore`",
|
||||
},
|
||||
batch_size: {
|
||||
name: "Batch size",
|
||||
desc: "Number of changes to sync at a time. Defaults to 50. Minimum is 2.",
|
||||
},
|
||||
batches_limit: {
|
||||
name: "Batch limit",
|
||||
desc: "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.",
|
||||
},
|
||||
useTimeouts: {
|
||||
name: "Use timeouts instead of heartbeats",
|
||||
desc: "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.",
|
||||
},
|
||||
concurrencyOfReadChunksOnline: {
|
||||
name: "Batch size of on-demand fetching",
|
||||
},
|
||||
minimumIntervalOfReadChunksOnline: {
|
||||
name: "The delay for consecutive on-demand fetches",
|
||||
},
|
||||
suspendFileWatching: {
|
||||
name: "Suspend file watching",
|
||||
desc: "Stop watching for file changes.",
|
||||
},
|
||||
suspendParseReplicationResult: {
|
||||
name: "Suspend database reflecting",
|
||||
desc: "Stop reflecting database changes to storage files.",
|
||||
},
|
||||
writeLogToTheFile: {
|
||||
name: "Write logs into the file",
|
||||
desc: "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.",
|
||||
},
|
||||
deleteMetadataOfDeletedFiles: {
|
||||
name: "Do not keep metadata of deleted files.",
|
||||
},
|
||||
useIndexedDBAdapter: {
|
||||
name: "(Obsolete) Use an old adapter for compatibility",
|
||||
desc: "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.",
|
||||
obsolete: true,
|
||||
},
|
||||
watchInternalFileChanges: {
|
||||
name: "Scan changes on customization sync",
|
||||
desc: "Do not use internal API",
|
||||
},
|
||||
doNotSuspendOnFetching: {
|
||||
name: "Fetch database with previous behaviour",
|
||||
},
|
||||
disableCheckingConfigMismatch: {
|
||||
name: "Do not check configuration mismatch before replication",
|
||||
},
|
||||
usePluginSync: {
|
||||
name: "Enable customization sync",
|
||||
},
|
||||
autoSweepPlugins: {
|
||||
name: "Scan customization automatically",
|
||||
desc: "Scan customization before replicating.",
|
||||
},
|
||||
autoSweepPluginsPeriodic: {
|
||||
name: "Scan customization periodically",
|
||||
desc: "Scan customization every 1 minute.",
|
||||
},
|
||||
notifyPluginOrSettingUpdated: {
|
||||
name: "Notify customized",
|
||||
desc: "Notify when other device has newly customized.",
|
||||
},
|
||||
remoteType: {
|
||||
name: "Remote Type",
|
||||
desc: "Remote server type",
|
||||
},
|
||||
endpoint: {
|
||||
name: "Endpoint URL",
|
||||
placeHolder: "https://........",
|
||||
},
|
||||
accessKey: {
|
||||
name: "Access Key",
|
||||
},
|
||||
secretKey: {
|
||||
name: "Secret Key",
|
||||
},
|
||||
region: {
|
||||
name: "Region",
|
||||
placeHolder: "auto",
|
||||
},
|
||||
bucket: {
|
||||
name: "Bucket Name",
|
||||
},
|
||||
useCustomRequestHandler: {
|
||||
name: "Use Custom HTTP Handler",
|
||||
desc: "Enable this if your Object Storage doesn't support CORS",
|
||||
},
|
||||
maxChunksInEden: {
|
||||
name: "Maximum Incubating Chunks",
|
||||
desc: "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.",
|
||||
},
|
||||
maxTotalLengthInEden: {
|
||||
name: "Maximum Incubating Chunk Size",
|
||||
desc: "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.",
|
||||
},
|
||||
maxAgeInEden: {
|
||||
name: "Maximum Incubation Period",
|
||||
desc: "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.",
|
||||
},
|
||||
settingSyncFile: {
|
||||
name: "Filename",
|
||||
desc: "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.",
|
||||
},
|
||||
preset: {
|
||||
name: "Presets",
|
||||
desc: "Apply preset configuration",
|
||||
},
|
||||
syncMode: {
|
||||
name: "Sync Mode",
|
||||
},
|
||||
periodicReplicationInterval: {
|
||||
name: "Periodic Sync interval",
|
||||
desc: "Interval (sec)",
|
||||
},
|
||||
syncInternalFilesBeforeReplication: {
|
||||
name: "Scan for hidden files before replication",
|
||||
},
|
||||
automaticallyDeleteMetadataOfDeletedFiles: {
|
||||
name: "Delete old metadata of deleted files on start-up",
|
||||
desc: "(Days passed, 0 to disable automatic-deletion)",
|
||||
},
|
||||
additionalSuffixOfDatabaseName: {
|
||||
name: "Database suffix",
|
||||
desc: "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.",
|
||||
},
|
||||
hashAlg: {
|
||||
name: configurationNames["hashAlg"]?.name || "",
|
||||
desc: "xxhash64 is the current default.",
|
||||
},
|
||||
deviceAndVaultName: {
|
||||
name: "Device name",
|
||||
desc: "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.",
|
||||
},
|
||||
displayLanguage: {
|
||||
name: "Display Language",
|
||||
desc: 'Not all messages have been translated. And, please revert to "Default" when reporting errors.',
|
||||
},
|
||||
enableChunkSplitterV2: {
|
||||
name: "Use splitting-limit-capped chunk splitter",
|
||||
desc: "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker."
|
||||
desc: "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.",
|
||||
},
|
||||
disableWorkerForGeneratingChunks: {
|
||||
name: "Do not split chunks in the background",
|
||||
desc: "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour)."
|
||||
desc: "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).",
|
||||
},
|
||||
processSmallFilesInUIThread: {
|
||||
name: "Process small files in the foreground",
|
||||
desc: "If enabled, the file under 1kb will be processed in the UI thread."
|
||||
desc: "If enabled, the file under 1kb will be processed in the UI thread.",
|
||||
},
|
||||
batchSaveMinimumDelay: {
|
||||
name: "Minimum delay for batch database updating",
|
||||
desc: "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving."
|
||||
desc: "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.",
|
||||
},
|
||||
batchSaveMaximumDelay: {
|
||||
name: "Maximum delay for batch database updating",
|
||||
desc: "Saving will be performed forcefully after this number of seconds."
|
||||
desc: "Saving will be performed forcefully after this number of seconds.",
|
||||
},
|
||||
"notifyThresholdOfRemoteStorageSize": {
|
||||
notifyThresholdOfRemoteStorageSize: {
|
||||
name: "Notify when the estimated remote storage size exceeds on start up",
|
||||
desc: "MB (0 to disable)."
|
||||
desc: "MB (0 to disable).",
|
||||
},
|
||||
"usePluginSyncV2": {
|
||||
name: "Enable per-file-saved customization sync",
|
||||
desc: "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions."
|
||||
usePluginSyncV2: {
|
||||
name: "Enable per-file customization sync",
|
||||
desc: "If enabled, efficient per-file customization sync will be used. A minor migration is required when enabling this feature, and all devices must be updated to v0.23.18. Enabling this feature will result in losing compatibility with older versions.",
|
||||
},
|
||||
"handleFilenameCaseSensitive": {
|
||||
handleFilenameCaseSensitive: {
|
||||
name: "Handle files as Case-Sensitive",
|
||||
desc: "If this enabled, All files are handled as case-Sensitive (Previous behaviour)."
|
||||
desc: "If this enabled, All files are handled as case-Sensitive (Previous behaviour).",
|
||||
},
|
||||
"doNotUseFixedRevisionForChunks": {
|
||||
name: "Compute revisions for chunks (Previous behaviour)",
|
||||
desc: "If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)"
|
||||
doNotUseFixedRevisionForChunks: {
|
||||
name: "Compute revisions for chunks",
|
||||
desc: "If this enabled, all chunks will be stored with the revision made from its content.",
|
||||
},
|
||||
"sendChunksBulkMaxSize": {
|
||||
sendChunksBulkMaxSize: {
|
||||
name: "Maximum size of chunks to send in one request",
|
||||
desc: "MB"
|
||||
desc: "MB",
|
||||
},
|
||||
"useAdvancedMode": {
|
||||
useAdvancedMode: {
|
||||
name: "Enable advanced features",
|
||||
// desc: "Enable advanced mode"
|
||||
},
|
||||
usePowerUserMode: {
|
||||
name: "Enable power user features",
|
||||
name: "Enable poweruser features",
|
||||
// desc: "Enable power user mode",
|
||||
// level: LEVEL_ADVANCED
|
||||
},
|
||||
useEdgeCaseMode: {
|
||||
name: "Enable edge case treatment features",
|
||||
},
|
||||
"enableDebugTools": {
|
||||
enableDebugTools: {
|
||||
name: "Enable Developers' Debug Tools.",
|
||||
desc: "You need a restart to apply this setting."
|
||||
}
|
||||
}
|
||||
desc: "Requires restart of Obsidian",
|
||||
},
|
||||
suppressNotifyHiddenFilesChange: {
|
||||
name: "Suppress notification of hidden files change",
|
||||
desc: "If enabled, the notification of hidden files change will be suppressed.",
|
||||
},
|
||||
syncMinimumInterval: {
|
||||
name: "Minimum interval for syncing",
|
||||
desc: "The minimum interval for automatic synchronisation on event.",
|
||||
},
|
||||
};
|
||||
function translateInfo(infoSrc: ConfigurationItem | undefined | false) {
|
||||
if (!infoSrc) return false;
|
||||
const info = { ...infoSrc };
|
||||
@@ -369,7 +388,6 @@ function translateInfo(infoSrc: ConfigurationItem | undefined | false) {
|
||||
return info;
|
||||
}
|
||||
function _getConfig(key: AllSettingItemKey) {
|
||||
|
||||
if (key in configurationNames) {
|
||||
return configurationNames[key as keyof ObsidianLiveSyncSettings];
|
||||
}
|
||||
@@ -385,4 +403,4 @@ export function getConfName(key: AllSettingItemKey) {
|
||||
const conf = getConfig(key);
|
||||
if (!conf) return `${key} (No info)`;
|
||||
return conf.name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export interface Confirm {
|
||||
askYesNo(message: string): Promise<"yes" | "no">;
|
||||
askString(title: string, key: string, placeholder: string, isPassword?: boolean): Promise<string | false>;
|
||||
|
||||
askYesNoDialog(message: string, opt: { title?: string, defaultOption?: "Yes" | "No", timeout?: number }): Promise<"yes" | "no">;
|
||||
|
||||
askSelectString(message: string, items: string[]): Promise<string>
|
||||
|
||||
askSelectStringDialogue(message: string, buttons: string[], opt: { title?: string, defaultAction: (typeof buttons)[number], timeout?: number }): Promise<(typeof buttons)[number] | false>;
|
||||
|
||||
askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void): void;
|
||||
|
||||
confirmWithMessage(title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false>;
|
||||
}
|
||||
@@ -1,18 +1,34 @@
|
||||
import type { FilePathWithPrefix, LoadedEntry, MetaEntry, UXFileInfo, UXFileInfoStub } from "../../lib/src/common/types";
|
||||
import type {
|
||||
FilePathWithPrefix,
|
||||
LoadedEntry,
|
||||
MetaEntry,
|
||||
UXFileInfo,
|
||||
UXFileInfoStub,
|
||||
} from "../../lib/src/common/types";
|
||||
|
||||
export interface DatabaseFileAccess {
|
||||
delete: (file: UXFileInfoStub | FilePathWithPrefix, rev?: string) => Promise<boolean>;
|
||||
store: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise<boolean>;
|
||||
storeContent(path: FilePathWithPrefix, content: string): Promise<boolean>;
|
||||
createChunks: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise<boolean>;
|
||||
fetch: (file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string, waitForReady?: boolean, skipCheck?: boolean) => Promise<UXFileInfo | false>;
|
||||
fetchEntryFromMeta: (meta: MetaEntry,
|
||||
waitForReady?: boolean, skipCheck?: boolean) => Promise<LoadedEntry | false>;
|
||||
fetchEntryMeta: (file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string, skipCheck?: boolean) => Promise<MetaEntry | false>;
|
||||
fetchEntry: (file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string, waitForReady?: boolean, skipCheck?: boolean) => Promise<LoadedEntry | false>;
|
||||
fetch: (
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady?: boolean,
|
||||
skipCheck?: boolean
|
||||
) => Promise<UXFileInfo | false>;
|
||||
fetchEntryFromMeta: (meta: MetaEntry, waitForReady?: boolean, skipCheck?: boolean) => Promise<LoadedEntry | false>;
|
||||
fetchEntryMeta: (
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
skipCheck?: boolean
|
||||
) => Promise<MetaEntry | false>;
|
||||
fetchEntry: (
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady?: boolean,
|
||||
skipCheck?: boolean
|
||||
) => Promise<LoadedEntry | false>;
|
||||
getConflictedRevs: (file: UXFileInfoStub | FilePathWithPrefix) => Promise<string[]>;
|
||||
// storeFromStorage: (file: UXFileInfoStub | FilePathWithPrefix, force?: boolean) => Promise<boolean>;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user