mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-02-22 20:18:48 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9503474d06 | ||
|
|
ddf7b243e4 | ||
|
|
f37561c3c1 | ||
|
|
f01429decc | ||
|
|
c0fcb66924 | ||
|
|
5f76b9809b | ||
|
|
d61d6fec37 | ||
|
|
9fdd622824 | ||
|
|
3b8d03a189 | ||
|
|
1f1a39e5a0 | ||
|
|
d0e92cff7a | ||
|
|
5addddc792 | ||
|
|
d978892661 | ||
|
|
cfb061a6a2 | ||
|
|
381055fc93 | ||
|
|
37d12916fc | ||
|
|
944aa846c4 | ||
|
|
abca808e29 | ||
|
|
90bb610133 | ||
|
|
9c5e9fe63b | ||
|
|
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 |
@@ -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
|
||||
|
||||
60
README.md
60
README.md
@@ -1,30 +1,39 @@
|
||||
<!-- For translation: 20240227r0 -->
|
||||
# Self-hosted LiveSync
|
||||
[Japanese docs](./README_ja.md) - [Chinese docs](./README_cn.md).
|
||||
|
||||
Self-hosted LiveSync is a community-implemented synchronization plugin, available on every obsidian-compatible platform and using CouchDB or Object Storage (e.g., MinIO, S3, R2, etc.) as the server.
|
||||
|
||||
Self-hosted LiveSync is a community-developed synchronisation plug-in available on all Obsidian-compatible platforms. It leverages robust server solutions such as CouchDB or object storage systems (e.g., MinIO, S3, R2, etc.) to ensure reliable data synchronisation.
|
||||
|
||||
Additionally, it supports peer-to-peer synchronisation using WebRTC now (experimental), enabling you to synchronise your notes directly between devices without relying on a server.
|
||||
|
||||

|
||||
|
||||
Note: This plugin cannot synchronise with the official "Obsidian Sync".
|
||||
>[!IMPORTANT]
|
||||
> This plug-in is not compatible with the official "Obsidian Sync" and cannot synchronise with it.
|
||||
|
||||
## Features
|
||||
- Synchronise vaults efficiently with minimal traffic.
|
||||
- Handle conflicting modifications effectively.
|
||||
- Automatically merge simple conflicts.
|
||||
- Use open-source solutions for the server.
|
||||
- Compatible solutions are supported.
|
||||
- Support end-to-end encryption.
|
||||
- Synchronise settings, snippets, themes, and plug-ins via [Customisation Sync (Beta)](#customization-sync) or [Hidden File Sync](#hiddenfilesync).
|
||||
- Enable WebRTC peer-to-peer synchronisation without requiring a `host` (Experimental).
|
||||
- This feature is still in the experimental stage. Please exercise caution when using it.
|
||||
- WebRTC is a peer-to-peer synchronisation method, so **at least one device must be online to synchronise**.
|
||||
- Instead of keeping your device online as a stable peer, you can use two pseudo-peers:
|
||||
- [livesync-serverpeer](https://github.com/vrtmrz/livesync-serverpeer): A pseudo-client running on the server for receiving and sending data between devices.
|
||||
- [webpeer](https://github.com/vrtmrz/livesync-commonlib/tree/main/apps/webpeer): A pseudo-client for receiving and sending data between devices.
|
||||
- A pre-built instance is available at [fancy-syncing.vrtmrz.net/webpeer](https://fancy-syncing.vrtmrz.net/webpeer/) (hosted on the vrtmrz blog site). This is also peer-to-peer. Feel free to use it.
|
||||
- For more information, refer to the [English explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync-en.html) or the [Japanese explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync).
|
||||
|
||||
- Synchronize vaults very efficiently with less traffic.
|
||||
- Good at conflicted modification.
|
||||
- Automatic merging for simple conflicts.
|
||||
- Using OSS solution for the server.
|
||||
- Compatible solutions can be used.
|
||||
- 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)
|
||||
|
||||
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.
|
||||
This plug-in may be particularly useful for researchers, engineers, and developers who need to keep their notes fully self-hosted for security reasons. It is also suitable for anyone seeking the peace of mind that comes with knowing their notes remain entirely private.
|
||||
|
||||
>[!IMPORTANT]
|
||||
> - Before installing or upgrading this plug-in, please back your vault up.
|
||||
> - Do not enable this plugin with another synchronization solution at the same time (including iCloud and Obsidian Sync).
|
||||
> - This is a synchronization plugin. Not a backup solution. Do not rely on this for backup.
|
||||
> - Before installing or upgrading this plug-in, please back up your vault.
|
||||
> - Do not enable this plug-in alongside another synchronisation solution at the same time (including iCloud and Obsidian Sync).
|
||||
> - For backups, we also provide a plug-in called [Differential ZIP Backup](https://github.com/vrtmrz/diffzip).
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -43,9 +52,11 @@ This plug-in might be useful for researchers, engineers, and developers with a n
|
||||
1. [Setup CouchDB on fly.io](docs/setup_flyio.md)
|
||||
2. [Setup your CouchDB](docs/setup_own_server.md)
|
||||
2. Configure plug-in in [Quick Setup](docs/quick_setup.md)
|
||||
|
||||
> [!TIP]
|
||||
> Now, fly.io has become not free. Fortunately, even though there are some issues, we are still able to use IBM Cloudant. Here is [Setup IBM Cloudant](docs/setup_cloudant.md). It will be updated soon!
|
||||
> Fly.io is no longer free. Fortunately, despite some issues, we can still use IBM Cloudant. Refer to [Setup IBM Cloudant](docs/setup_cloudant.md).
|
||||
> And also, we can use peer-to-peer synchronisation without a server. Or very cheap Object Storage -- Cloudflare R2 can be used for free.
|
||||
> HOWEVER, most importantly, we can use the server that we trust. Therefore, please set up your own server.
|
||||
> CouchDB can be run on a Raspberry Pi. (But please be careful about the security of your server).
|
||||
|
||||
|
||||
## Information in StatusBar
|
||||
@@ -75,17 +86,14 @@ Synchronization status is shown in the status bar with the following icons.
|
||||
|
||||
To prevent file and database corruption, please wait to stop Obsidian until all progress indicators have disappeared as possible (The plugin will also try to resume, though). Especially in case of if you have deleted or renamed files.
|
||||
|
||||
|
||||
|
||||
## Tips and Troubleshooting
|
||||
If you are having problems getting the plugin working see: [Tips and Troubleshooting](docs/troubleshooting.md)
|
||||
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">
|
||||
The project has been in continual progress and harmony thanks to:
|
||||
- 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.
|
||||
|
||||
|
||||
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.
|
||||
BIN
docs/all_toggles.png
Normal file
BIN
docs/all_toggles.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -1,6 +1,6 @@
|
||||
# Keep newborn chunks in Eden.
|
||||
# Keep newborn chunks in Eden
|
||||
|
||||
NOTE: This is the planned feature design document. This is planned, but not be implemented now (v0.23.3). This has not reached the design freeze and will be added to from time to time.
|
||||
Notice: deprecated. please refer to the result section of this document.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -19,15 +19,18 @@ Reduce the number of chunks which in volatile, and reduce the usage of storage o
|
||||
- The problem is that this unnecessary chunking slows down both local and remote operations.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
- The implementation must be able to control the size of the document appropriately so that it does not become non-transferable (1).
|
||||
- The implementation must be such that data corruption can be avoided even if forward compatibility is not maintained; due to the nature of Self-hosted LiveSync, backward version connexions are expected.
|
||||
- The implementation must be such that data corruption can be avoided even if forward compatibility is not maintained; due to the nature of Self-hosted LiveSync, backward version connexions are expected.
|
||||
- Viewed as a feature:
|
||||
- This feature should be disabled for migration users.
|
||||
- This feature should be enabled for new users and after rebuilds of migrated users.
|
||||
- Therefore, back into the implementation view, Ideally, the implementation should be such that data recovery can be achieved by immediately upgrading after replication.
|
||||
|
||||
## Outlined methods and implementation plans
|
||||
|
||||
### Abstract
|
||||
|
||||
To store and transfer only stable chunks independently and share them from multiple documents after stabilisation, new chunks, i.e. chunks that are considered non-stable, are modified to be stored in the document and transferred with the document. In this case, care should be taken not to exceed prerequisite (1).
|
||||
|
||||
If this is achieved, the non-leaf document will not be transferred, and even if it is, the chunk will be stored in the document, so that the size can be reduced by the compaction.
|
||||
@@ -40,11 +43,11 @@ Details are given below.
|
||||
type EntryWithEden = {
|
||||
eden: {
|
||||
[key: DocumentID]: {
|
||||
data: string,
|
||||
epoch: number, // The document revision which this chunk has been born.
|
||||
}
|
||||
}
|
||||
}
|
||||
data: string;
|
||||
epoch: number; // The document revision which this chunk has been born.
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
2. The following configuration items are added:
|
||||
Note: These configurations should be shared as `Tweaks value` between each client.
|
||||
@@ -63,6 +66,7 @@ Details are given below.
|
||||
5. In End-to-End Encryption, property `eden` of documents will also be encrypted.
|
||||
|
||||
### Note
|
||||
|
||||
- When this feature has been enabled, forward compatibility is temporarily lost. However, it is detected as missing chunks, and this data is not reflected in the storage in the old version. Therefore, no data loss will occur.
|
||||
|
||||
## Test strategy
|
||||
@@ -77,5 +81,26 @@ Details are given below.
|
||||
- Indeed, we lack a fulfilled configuration table. Efforts will be made and, if they can be produced, this document will then be referenced. But not required while in the experimental or beta feature.
|
||||
- However, this might be an essential feature. Further efforts are desired.
|
||||
|
||||
## Results from actual operation
|
||||
|
||||
After implementing this feature, we have been using it for a while. The following results were obtained.
|
||||
|
||||
- Drawbacks were thought not to be a problem, but they were actually a problem:
|
||||
- A document with `Eden` has a quite larger history compared to a document without `Eden`.
|
||||
- Self-hosted LiveSync does not perform compaction aggressively, which results in the remote database becoming partially bloated.
|
||||
- Compaction of the Remote Database (CouchDB) requires the same amount of free space as the size of the database. Therefore, it is not possible to perform compaction on a remote database if we reached to the maximum size of the database. It means that when we detect it, it is too late.
|
||||
- We have mentioned that `We need compaction` in previous sections. However, but it was so hard to be determined whether the compaction is required or not, until the database is bloated. (Of course, it requires some time to compact the database, and, literally, some document loses its history. It is not a good idea to perform frequently and meaninglessly. We need manual decision, but indeed difficult to normal users).
|
||||
|
||||
### Consideration and Conclusion
|
||||
To be described after implemented, tested, and, released.
|
||||
|
||||
This feature results in two aspects:
|
||||
|
||||
- For the users who are familiar with the CouchDB, this feature is a bit useful. They can watch and handle the database by themselves.
|
||||
- For the users who are not familiar with the CouchDB, i.e., normal users, this feature is not so useful, either. They are not familiar with the database, and they do not know how to handle it. Therefore, they cannot decide whether the compaction is required or not.
|
||||
|
||||
Hence, this feature would be kept as an experimental feature, but it is not enabled by default. In addition to that, it is marked as deprecated. Detailed notice will be noisy for the users who are not familiar with the CouchDB. Details would be kept in this document, for the future.
|
||||
It is not recommended to use this feature, unless the person who is familiar with the CouchDB and the database management.
|
||||
|
||||
Vorotamoroz has written this document. Bias: I am the first author of this plug-in, familiar with the CouchDB.
|
||||
|
||||
Research and development has been frozen on 2025-04-11. But, bugs will be fixed if they are found. Please feel free to report them.
|
||||
|
||||
@@ -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,36 @@ $ 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
|
||||
```
|
||||
If you prefer a compose file instead of docker run, here is the equivalent below:
|
||||
```
|
||||
services:
|
||||
couchdb:
|
||||
image: couchdb:latest
|
||||
container_name: couchdb-for-ols
|
||||
user: 1000:1000
|
||||
environment:
|
||||
- COUCHDB_USER=${username}
|
||||
- COUCHDB_PASSWORD=${password}
|
||||
volumes:
|
||||
- ./couchdb-data:/opt/couchdb/data
|
||||
- ./couchdb-etc:/opt/couchdb/etc/local.d
|
||||
ports:
|
||||
- 5984:5984
|
||||
restart: unless-stopped
|
||||
```
|
||||
### 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
|
||||
```
|
||||
|
||||
If it results like following:
|
||||
If it results like the following:
|
||||
```
|
||||
-- Configuring CouchDB by REST APIs... -->
|
||||
{"ok":true}
|
||||
@@ -80,7 +97,7 @@ 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
|
||||
@@ -99,12 +116,12 @@ You will then get the following output:
|
||||
:
|
||||
:
|
||||
```
|
||||
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
|
||||
@@ -116,6 +133,13 @@ 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
|
||||
|
||||
@@ -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,8 +1,15 @@
|
||||
<!-- 2024-02-15 -->
|
||||
# Tips and Troubleshooting
|
||||
|
||||
|
||||
- [Tips and Troubleshooting](#tips-and-troubleshooting)
|
||||
- [Tips](#tips)
|
||||
- [CORS configuration with reverse proxy](#cors-configuration-with-reverse-proxy)
|
||||
- [Nginx](#nginx)
|
||||
- [Nginx and subdirectory](#nginx-and-subdirectory)
|
||||
- [Caddy](#caddy)
|
||||
- [Caddy and subdirectory](#caddy-and-subdirectory)
|
||||
- [Apache](#apache)
|
||||
- [Show all setting panes](#show-all-setting-panes)
|
||||
- [How to resolve `Tweaks Mismatched of Changed`](#how-to-resolve-tweaks-mismatched-of-changed)
|
||||
- [Notable bugs and fixes](#notable-bugs-and-fixes)
|
||||
- [Binary files get bigger on iOS](#binary-files-get-bigger-on-ios)
|
||||
- [Some setting name has been changed](#some-setting-name-has-been-changed)
|
||||
@@ -17,22 +24,133 @@
|
||||
- [How can I use the DevTools?](#how-can-i-use-the-devtools)
|
||||
- [Checking the network log](#checking-the-network-log)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [While using Cloudflare Tunnels, often Obsidian API fallback and `524` error occurs.](#while-using-cloudflare-tunnels-often-obsidian-api-fallback-and-524-error-occurs)
|
||||
- [On the mobile device, cannot synchronise on the local network!](#on-the-mobile-device-cannot-synchronise-on-the-local-network)
|
||||
- [I think that something bad happening on the vault...](#i-think-that-something-bad-happening-on-the-vault)
|
||||
- [Tips](#tips)
|
||||
- [How to resolve `Tweaks Mismatched of Changed`](#how-to-resolve-tweaks-mismatched-of-changed)
|
||||
- [Old tips](#old-tips)
|
||||
|
||||
<!-- - -->
|
||||
|
||||
## Tips
|
||||
|
||||
### CORS configuration with reverse proxy
|
||||
|
||||
- IMPORTANT: CouchDB handles CORS by itself. Do not process CORS on the reverse
|
||||
proxy.
|
||||
- Do not process `Option` requests on the reverse proxy!
|
||||
- Make sure `host` and `X-Forwarded-For` headers are forwarded to the CouchDB.
|
||||
- If you are using a subdirectory, make sure to handle it properly. More
|
||||
detailed information is in the
|
||||
[CouchDB documentation](https://docs.couchdb.org/en/stable/best-practices/reverse-proxies.html).
|
||||
|
||||
Minimal configurations are as follows:
|
||||
|
||||
#### Nginx
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:5984;
|
||||
proxy_redirect off;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
```
|
||||
|
||||
#### Nginx and subdirectory
|
||||
|
||||
```nginx
|
||||
location /couchdb {
|
||||
rewrite ^ $request_uri;
|
||||
rewrite ^/couchdb/(.*) /$1 break;
|
||||
proxy_pass http://localhost:5984$uri;
|
||||
proxy_redirect off;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /_session {
|
||||
proxy_pass http://localhost:5984/_session;
|
||||
proxy_redirect off;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
```
|
||||
|
||||
#### Caddy
|
||||
|
||||
```caddyfile
|
||||
domain.com {
|
||||
reverse_proxy localhost:5984
|
||||
}
|
||||
```
|
||||
|
||||
#### Caddy and subdirectory
|
||||
|
||||
```caddyfile
|
||||
domain.com {
|
||||
reverse_proxy /couchdb/* localhost:5984
|
||||
reverse_proxy /_session/* localhost:5984/_session
|
||||
}
|
||||
```
|
||||
|
||||
#### Apache
|
||||
|
||||
Sorry, Apache is not recommended for CouchDB. Omit the configuration from here.
|
||||
Please refer to the
|
||||
[Official documentation](https://docs.couchdb.org/en/stable/best-practices/reverse-proxies.html#reverse-proxying-with-apache-http-server).
|
||||
|
||||
### Show all setting panes
|
||||
|
||||
Full pane is not shown by default. To show all panes, please toggle all in
|
||||
`🧙♂️ Wizard` -> `Enable extra and advanced features`.
|
||||
|
||||
For your information, the all panes are as follows:
|
||||

|
||||
|
||||
### How to resolve `Tweaks Mismatched of Changed`
|
||||
|
||||
(Since v0.23.17)
|
||||
|
||||
If you have changed some configurations or tweaks which should be unified
|
||||
between the devices, you will be asked how to reflect (or not) other devices at
|
||||
the next synchronisation. It also occurs on the device itself, where changes are
|
||||
made, to prevent unexpected configuration changes from unwanted propagation.\
|
||||
(We may thank this behaviour if we have synchronised or backed up and restored
|
||||
Self-hosted LiveSync. At least, for me so).
|
||||
|
||||
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.
|
||||
|
||||
Rest assured that in most cases we can choose `Use configured`. (Unless you are
|
||||
certain that you have not changed the configuration).
|
||||
|
||||
If we see it for the first time, it reflects the settings of the device that has
|
||||
been synchronised with the remote for the first time since the upgrade.
|
||||
Probably, we can accept that.
|
||||
|
||||
<!-- Add here -->
|
||||
|
||||
## 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.
|
||||
- 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 |
|
||||
@@ -46,103 +164,178 @@
|
||||
|
||||
### Why `Use an old adapter for compatibility` is somehow enabled in my vault?
|
||||
|
||||
Because you are a compassionate and experienced user. Before v0.17.16, we used an old adapter for the local database. At that time, current default adapter has not been stable.
|
||||
The new adapter has better performance and has a new feature like purging. Therefore, we should use new adapters and current default is so.
|
||||
Because you are a compassionate and experienced user. Before v0.17.16, we used
|
||||
an old adapter for the local database. At that time, current default adapter has
|
||||
not been stable. The new adapter has better performance and has a new feature
|
||||
like purging. Therefore, we should use new adapters and current default is so.
|
||||
|
||||
However, when switching from an old adapter to a new adapter, some converting or local database rebuilding is required, and it takes a few time. It was a long time ago now, but we once inconvenienced everyone in a hurry when we changed the format of our database.
|
||||
For these reasons, this toggle is automatically on if we have upgraded from vault which using an old adapter.
|
||||
However, when switching from an old adapter to a new adapter, some converting or
|
||||
local database rebuilding is required, and it takes a few time. It was a long
|
||||
time ago now, but we once inconvenienced everyone in a hurry when we changed the
|
||||
format of our database. For these reasons, this toggle is automatically on if we
|
||||
have upgraded from vault which using an old adapter.
|
||||
|
||||
When you rebuild everything or fetch from the remote again, you will be asked to switch this.
|
||||
When you rebuild everything or fetch from the remote again, you will be asked to
|
||||
switch this.
|
||||
|
||||
Therefore, experienced users (especially those stable enough not to have to rebuild the database) may have this toggle enabled in their Vault.
|
||||
Please disable it when you have enough time.
|
||||
Therefore, experienced users (especially those stable enough not to have to
|
||||
rebuild the database) may have this toggle enabled in their Vault. 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.
|
||||
|
||||
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.
|
||||

|
||||
|
||||
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.
|
||||
|
||||
However, the logs would not be kept so long and cleared when restarted. If you want to check the logs, please enable `Write logs into the file` temporarily.
|
||||
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.
|
||||
|
||||
However, the logs would not be kept so long and cleared when restarted. If you
|
||||
want to check the logs, please enable `Write logs into the file` temporarily.
|
||||
|
||||

|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - Writing logs into the file will impact the performance.
|
||||
> - Please make sure that you have erased all your confidential information before reporting issue.
|
||||
> - 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).
|
||||
|
||||
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.
|
||||
|
||||
And one more thing, we can handle the conflicts on any device even though it has happened on other devices. This means that conflicts will happen in the past, after the time we have synchronised. Hence we cannot collect and delete the unused chunks even though if we are not currently referenced.
|
||||
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.
|
||||
|
||||
To shrink the database size, `Rebuild everything` only reliably and effectively. But do not worry, if we have synchronised well. We have the actual and real files. Only it takes a bit of time and traffics.
|
||||
And one more thing, we can handle the conflicts on any device even though it has
|
||||
happened on other devices. This means that conflicts will happen in the past,
|
||||
after the time we have synchronised. Hence we cannot collect and delete the
|
||||
unused chunks even though if we are not currently referenced.
|
||||
|
||||
To shrink the database size, `Rebuild everything` only reliably and effectively.
|
||||
But do not worry, if we have synchronised well. We have the actual and real
|
||||
files. Only it takes a bit of time and traffics.
|
||||
|
||||
### 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.**
|
||||

|
||||
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.**\
|
||||

|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<!-- Add here -->
|
||||
|
||||
### While using Cloudflare Tunnels, often Obsidian API fallback and `524` error occurs.
|
||||
|
||||
A `524` error occurs when the request to the server is not completed within a
|
||||
`specified time`. This is a timeout error from Cloudflare. From the reported
|
||||
issue, it seems to be 100 seconds. (#627).
|
||||
|
||||
Therefore, this error returns from Cloudflare, not from the server. Hence, the
|
||||
result contains no CORS field. It means that this response makes the Obsidian
|
||||
API fallback.
|
||||
|
||||
However, even if the Obsidian API fallback occurs, the request is still not
|
||||
completed within the `specified time`, 100 seconds.
|
||||
|
||||
To solve this issue, we need to configure the timeout settings.
|
||||
|
||||
Please enable the toggle in `💪 Power users` -> `CouchDB Connection Tweak` ->
|
||||
`Use timeouts instead of heartbeats`.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Tips
|
||||
If there is `redflag.md`, Self-hosted LiveSync suspends all database and storage
|
||||
processes.
|
||||
|
||||
### How to resolve `Tweaks Mismatched of Changed`
|
||||
There are some options to use `redflag.md`.
|
||||
|
||||
(Since v0.23.17)
|
||||
| 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. |
|
||||
|
||||
If you have changed some configurations or tweaks which should be unified between the devices, you will be asked how to reflect (or not) other devices at the next synchronisation. It also occurs on the device itself, where changes are made, to prevent unexpected configuration changes from unwanted propagation.
|
||||
(We may thank this behaviour if we have synchronised or backed up and restored Self-hosted LiveSync. At least, for me so).
|
||||
|
||||
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.
|
||||
|
||||
Rest assured that in most cases we can choose `Use configured`. (Unless you are certain that you have not changed the configuration).
|
||||
|
||||
If we see it for the first time, it reflects the settings of the device that has been synchronised with the remote for the first time since the upgrade. Probably, we can accept that.
|
||||
|
||||
<!-- Add here -->
|
||||
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).
|
||||
|
||||
### 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)
|
||||
|
||||
Repo is here: [obsidian-livesync-webclip](https://github.com/vrtmrz/obsidian-livesync-webclip). (Docs are a work in progress.)
|
||||
- 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.7",
|
||||
"version": "0.24.28",
|
||||
"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",
|
||||
|
||||
12854
package-lock.json
generated
12854
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
61
package.json
61
package.json
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.24.7",
|
||||
"version": "0.24.28",
|
||||
"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",
|
||||
"postbakei18n": "prettier --config ./.prettierrc ./src/lib/src/common/messages/*.ts --write --log-level error",
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"prebuild": "npm run bakei18n",
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"buildDev": "node esbuild.config.mjs dev",
|
||||
"lint": "eslint src",
|
||||
@@ -21,9 +24,12 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@chialab/esbuild-plugin-worker": "^0.18.1",
|
||||
"@eslint/compat": "^1.2.7",
|
||||
"@eslint/eslintrc": "^3.3.0",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/node": "^22.13.8",
|
||||
"@types/pouchdb": "^6.4.2",
|
||||
"@types/pouchdb-adapter-http": "^6.1.6",
|
||||
"@types/pouchdb-adapter-idb": "^6.1.7",
|
||||
@@ -32,17 +38,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",
|
||||
"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",
|
||||
"@typescript-eslint/eslint-plugin": "8.25.0",
|
||||
"@typescript-eslint/parser": "8.25.0",
|
||||
"builtin-modules": "5.0.0",
|
||||
"esbuild": "0.25.0",
|
||||
"esbuild-svelte": "^0.9.0",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-svelte": "^3.0.2",
|
||||
"events": "^3.3.0",
|
||||
"obsidian": "^1.6.6",
|
||||
"postcss": "^8.4.45",
|
||||
"obsidian": "^1.8.7",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
@@ -54,26 +60,31 @@
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"prettier": "^3.3.3",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-preprocess": "^6.0.2",
|
||||
"terser": "^5.31.6",
|
||||
"prettier": "3.5.2",
|
||||
"svelte": "5.28.6",
|
||||
"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.4",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.645.0",
|
||||
"@smithy/fetch-http-handler": "^3.2.4",
|
||||
"@smithy/protocol-http": "^4.1.0",
|
||||
"@smithy/querystring-builder": "^3.0.3",
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
"@smithy/md5-js": "^4.0.2",
|
||||
"@smithy/middleware-apply-body-checksum": "^4.1.0",
|
||||
"@smithy/fetch-http-handler": "^5.0.2",
|
||||
"@smithy/protocol-http": "^5.1.0",
|
||||
"@smithy/querystring-builder": "^4.0.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.0",
|
||||
"idb": "^8.0.3",
|
||||
"minimatch": "^10.0.1",
|
||||
"octagonal-wheels": "^0.1.21",
|
||||
"svelte-check": "^4.0.4",
|
||||
"octagonal-wheels": "^0.1.30",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"svelte-check": "^4.1.7",
|
||||
"trystero": "^0.21.5",
|
||||
"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,49 +1,45 @@
|
||||
import type { FilePathWithPrefix, ObsidianLiveSyncSettings } from "../lib/src/common/types";
|
||||
import { eventHub } from "../lib/src/hub/hub";
|
||||
import type ObsidianLiveSyncPlugin from "../main";
|
||||
|
||||
export const EVENT_LAYOUT_READY = "layout-ready";
|
||||
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_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";
|
||||
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
[EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined;
|
||||
[EVENT_FILE_SAVED]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETUP_URI]: undefined;
|
||||
[EVENT_REQUEST_COPY_SETUP_URI]: undefined;
|
||||
[EVENT_REQUEST_RELOAD_SETTING_TAB]: undefined;
|
||||
[EVENT_PLUGIN_UNLOADED]: undefined;
|
||||
[EVENT_SETTING_SAVED]: ObsidianLiveSyncSettings;
|
||||
[EVENT_PLUGIN_LOADED]: ObsidianLiveSyncPlugin;
|
||||
[EVENT_LAYOUT_READY]: undefined;
|
||||
"event-file-changed": { file: FilePathWithPrefix; automated: boolean };
|
||||
"document-stub-created": {
|
||||
toc: Set<string>;
|
||||
stub: { [key: string]: { [key: string]: Map<string, Record<string, string>> } };
|
||||
};
|
||||
[EVENT_PLUGIN_UNLOADED]: undefined;
|
||||
[EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTINGS]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTING_WIZARD]: undefined;
|
||||
[EVENT_FILE_RENAMED]: { newPath: FilePathWithPrefix; old: FilePathWithPrefix };
|
||||
[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 };
|
||||
|
||||
@@ -88,3 +88,4 @@ export const ICXHeader = "ix:";
|
||||
|
||||
export const FileWatchEventQueueMax = 10;
|
||||
export const configURIBase = "obsidian://setuplivesync?settings=";
|
||||
export const configURIBaseQR = "obsidian://setuplivesync?settingsQR=";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type AnyEntry,
|
||||
type CouchDBCredentials,
|
||||
type DocumentID,
|
||||
type EntryHasPath,
|
||||
type FilePath,
|
||||
@@ -30,6 +31,8 @@ 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";
|
||||
import { AuthorizationHeaderGenerator } from "../lib/src/replication/httplib.ts";
|
||||
|
||||
export { scheduleTask, cancelTask, cancelAllTasks } from "../lib/src/concurrency/task.ts";
|
||||
|
||||
@@ -229,17 +232,17 @@ export const _requestToCouchDBFetch = async (
|
||||
|
||||
export const _requestToCouchDB = async (
|
||||
baseUri: string,
|
||||
username: string,
|
||||
password: string,
|
||||
credentials: CouchDBCredentials,
|
||||
origin: string,
|
||||
path?: string,
|
||||
body?: any,
|
||||
method?: string
|
||||
method?: string,
|
||||
customHeaders?: Record<string, 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, origin: origin };
|
||||
// Create each time to avoid caching.
|
||||
const authHeaderGen = new AuthorizationHeaderGenerator();
|
||||
const authHeader = await authHeaderGen.getAuthorizationHeader(credentials);
|
||||
const transformedHeaders: Record<string, string> = { authorization: authHeader, origin: origin, ...customHeaders };
|
||||
const uri = `${baseUri}/${path}`;
|
||||
const requestParam: RequestUrlParam = {
|
||||
url: uri,
|
||||
@@ -250,6 +253,9 @@ export const _requestToCouchDB = async (
|
||||
};
|
||||
return await requestUrl(requestParam);
|
||||
};
|
||||
/**
|
||||
* @deprecated Use requestToCouchDBWithCredentials instead.
|
||||
*/
|
||||
export const requestToCouchDB = async (
|
||||
baseUri: string,
|
||||
username: string,
|
||||
@@ -257,12 +263,34 @@ export const requestToCouchDB = async (
|
||||
origin: string = "",
|
||||
key?: string,
|
||||
body?: string,
|
||||
method?: string
|
||||
method?: string,
|
||||
customHeaders?: Record<string, string>
|
||||
) => {
|
||||
const uri = `_node/_local/_config${key ? "/" + key : ""}`;
|
||||
return await _requestToCouchDB(baseUri, username, password, origin, uri, body, method);
|
||||
return await _requestToCouchDB(
|
||||
baseUri,
|
||||
{ username, password, type: "basic" },
|
||||
origin,
|
||||
uri,
|
||||
body,
|
||||
method,
|
||||
customHeaders
|
||||
);
|
||||
};
|
||||
|
||||
export function requestToCouchDBWithCredentials(
|
||||
baseUri: string,
|
||||
credentials: CouchDBCredentials,
|
||||
origin: string = "",
|
||||
key?: string,
|
||||
body?: string,
|
||||
method?: string,
|
||||
customHeaders?: Record<string, string>
|
||||
) {
|
||||
const uri = `_node/_local/_config${key ? "/" + key : ""}`;
|
||||
return _requestToCouchDB(baseUri, credentials, origin, uri, body, method, customHeaders);
|
||||
}
|
||||
|
||||
export const BASE_IS_NEW = Symbol("base");
|
||||
export const TARGET_IS_NEW = Symbol("target");
|
||||
export const EVEN = Symbol("even");
|
||||
@@ -493,3 +521,163 @@ export function onlyInNTimes(n: number, proc: (progress: number) => any) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import type { IObsidianModule } from "../../modules/AbstractObsidianModule.ts";
|
||||
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "../../common/events.ts";
|
||||
import { PluginDialogModal } from "./PluginDialogModal.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
|
||||
const d = "\u200b";
|
||||
const d2 = "\n";
|
||||
@@ -446,7 +447,7 @@ export class ConfigSync extends LiveSyncCommands implements IObsidianModule {
|
||||
this.showPluginSyncModal();
|
||||
},
|
||||
});
|
||||
this.addRibbonIcon("custom-sync", "Show Customization sync", () => {
|
||||
this.addRibbonIcon("custom-sync", $msg("cmdConfigSync.showCustomizationSync"), () => {
|
||||
this.showPluginSyncModal();
|
||||
}).addClass("livesync-ribbon-showcustom");
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, () => this.showPluginSyncModal());
|
||||
@@ -1305,7 +1306,7 @@ export class ConfigSync extends LiveSyncCommands implements IObsidianModule {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const docXDoc = await this.localDatabase.getDBEntryFromMeta(old, {}, false, false);
|
||||
const docXDoc = await this.localDatabase.getDBEntryFromMeta(old, false, false);
|
||||
if (docXDoc == false) {
|
||||
throw "Could not load the document";
|
||||
}
|
||||
@@ -1439,7 +1440,7 @@ export class ConfigSync extends LiveSyncCommands implements IObsidianModule {
|
||||
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
const oldC = await this.localDatabase.getDBEntryFromMeta(old, {}, false, false);
|
||||
const oldC = await this.localDatabase.getDBEntryFromMeta(old, false, false);
|
||||
if (oldC) {
|
||||
const d = (await deserialize(getDocDataAsArray(oldC.data), {})) as PluginDataEx;
|
||||
if (d.files.length == dt.files.length) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { ConfigSync, PluginDataExDisplayV2, type IPluginDataExDisplay, type PluginDataExFile } 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)) {
|
||||
@@ -329,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>
|
||||
@@ -351,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 != ""}
|
||||
@@ -367,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;
|
||||
}
|
||||
@@ -20,7 +21,7 @@ export class PluginDialogModal extends Modal {
|
||||
this.contentEl.style.flexDirection = "column";
|
||||
this.titleEl.setText("Customization Sync (Beta3)");
|
||||
if (!this.component) {
|
||||
this.component = new PluginPane({
|
||||
this.component = mount(PluginPane, {
|
||||
target: contentEl,
|
||||
props: { plugin: this.plugin },
|
||||
});
|
||||
@@ -29,7 +30,7 @@ export class PluginDialogModal extends Modal {
|
||||
|
||||
onClose() {
|
||||
if (this.component) {
|
||||
this.component.$destroy();
|
||||
void unmount(this.component);
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,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;
|
||||
@@ -55,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,
|
||||
@@ -81,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;
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
readContent,
|
||||
createBlob,
|
||||
fireAndForget,
|
||||
type CustomRegExp,
|
||||
getFileRegExp,
|
||||
} from "../../lib/src/common/utils.ts";
|
||||
import {
|
||||
compareMTime,
|
||||
@@ -164,12 +166,11 @@ export class HiddenFileSync extends LiveSyncCommands implements IObsidianModule
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
updateSettingCache() {
|
||||
const ignorePatterns = this.settings.syncInternalFilesIgnorePatterns
|
||||
.replace(/\n| /g, "")
|
||||
.split(",")
|
||||
.filter((e) => e)
|
||||
.map((e) => new RegExp(e, "i"));
|
||||
const ignorePatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
|
||||
this.ignorePatterns = ignorePatterns;
|
||||
const targetFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
|
||||
this.targetPatterns = targetFilter;
|
||||
|
||||
this.shouldSkipFile = [] as FilePathWithPrefixLC[];
|
||||
// Exclude files handled by customization sync
|
||||
const configDir = normalizePath(this.app.vault.configDir);
|
||||
@@ -219,12 +220,10 @@ export class HiddenFileSync extends LiveSyncCommands implements IObsidianModule
|
||||
? this.settings.syncInternalFilesInterval * 1000
|
||||
: 0
|
||||
);
|
||||
const ignorePatterns = this.settings.syncInternalFilesIgnorePatterns
|
||||
.replace(/\n| /g, "")
|
||||
.split(",")
|
||||
.filter((e) => e)
|
||||
.map((e) => new RegExp(e, "i"));
|
||||
const ignorePatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
|
||||
this.ignorePatterns = ignorePatterns;
|
||||
const targetFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
|
||||
this.targetPatterns = targetFilter;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -701,7 +700,7 @@ Offline Changed files: ${processFiles.length}`;
|
||||
?.filter((e) => e.status == "available" && Number(e.rev.split("-")[0]) < conflictedRevNo)
|
||||
.first()?.rev ?? "";
|
||||
const result = await this.plugin.localDatabase.mergeObject(
|
||||
path,
|
||||
doc.path,
|
||||
commonBase,
|
||||
doc._rev,
|
||||
conflictedRev
|
||||
@@ -1490,7 +1489,7 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
const fileOnDB = await this.localDatabase.getDBEntryFromMeta(metaOnDB, {}, false, true, true);
|
||||
const fileOnDB = await this.localDatabase.getDBEntryFromMeta(metaOnDB, false, true);
|
||||
if (fileOnDB === false) {
|
||||
throw new Error(`Failed to read file from database:${storageFilePath}`);
|
||||
}
|
||||
@@ -1683,14 +1682,12 @@ ${messageFetch}${messageOverwrite}${messageMerge}
|
||||
// <-- Configuration handling
|
||||
|
||||
// --> Local Storage SubFunctions
|
||||
ignorePatterns: RegExp[] = [];
|
||||
ignorePatterns: CustomRegExp[] = [];
|
||||
targetPatterns: CustomRegExp[] = [];
|
||||
async scanInternalFileNames() {
|
||||
const configDir = normalizePath(this.app.vault.configDir);
|
||||
const ignoreFilter = this.settings.syncInternalFilesIgnorePatterns
|
||||
.replace(/\n| /g, "")
|
||||
.split(",")
|
||||
.filter((e) => e)
|
||||
.map((e) => new RegExp(e, "i"));
|
||||
const ignoreFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
|
||||
const targetFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
|
||||
const synchronisedInConfigSync = !this.settings.usePluginSync
|
||||
? []
|
||||
: Object.values(this.settings.pluginSyncExtendedSetting)
|
||||
@@ -1701,7 +1698,7 @@ ${messageFetch}${messageOverwrite}${messageMerge}
|
||||
const root = this.app.vault.getRoot();
|
||||
const findRoot = root.path;
|
||||
|
||||
const filenames = (await this.getFiles(findRoot, [], undefined, ignoreFilter))
|
||||
const filenames = (await this.getFiles(findRoot, [], targetFilter, ignoreFilter))
|
||||
.filter((e) => e.startsWith("."))
|
||||
.filter((e) => !e.startsWith(".trash"));
|
||||
const files = filenames.filter((path) =>
|
||||
@@ -1737,7 +1734,7 @@ ${messageFetch}${messageOverwrite}${messageMerge}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getFiles(path: string, ignoreList: string[], filter?: RegExp[], ignoreFilter?: RegExp[]) {
|
||||
async getFiles(path: string, ignoreList: string[], filter?: CustomRegExp[], ignoreFilter?: CustomRegExp[]) {
|
||||
let w: ListedFiles;
|
||||
try {
|
||||
w = await this.app.vault.adapter.list(path);
|
||||
@@ -1746,26 +1743,32 @@ ${messageFetch}${messageOverwrite}${messageMerge}
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return [];
|
||||
}
|
||||
const filesSrc = [
|
||||
...w.files
|
||||
.filter((e) => !ignoreList.some((ee) => e.endsWith(ee)))
|
||||
.filter((e) => !filter || filter.some((ee) => e.match(ee)))
|
||||
.filter((e) => !ignoreFilter || ignoreFilter.every((ee) => !e.match(ee))),
|
||||
];
|
||||
let files = [] as string[];
|
||||
for (const file of filesSrc) {
|
||||
if (!(await this.plugin.$$isIgnoredByIgnoreFiles(file))) {
|
||||
files.push(file);
|
||||
for (const file of w.files) {
|
||||
if (ignoreList && ignoreList.length > 0) {
|
||||
if (ignoreList.some((e) => file.endsWith(e))) continue;
|
||||
}
|
||||
if (filter && filter.length > 0) {
|
||||
if (!filter.some((e) => e.test(file))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (ignoreFilter && ignoreFilter.some((ee) => ee.test(file))) {
|
||||
continue;
|
||||
}
|
||||
if (await this.plugin.$$isIgnoredByIgnoreFiles(file)) continue;
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
L1: for (const v of w.folders) {
|
||||
for (const ignore of ignoreList) {
|
||||
if (v.endsWith(ignore)) {
|
||||
continue L1;
|
||||
}
|
||||
}
|
||||
if (ignoreFilter && ignoreFilter.some((e) => v.match(e))) {
|
||||
if (
|
||||
ignoreFilter &&
|
||||
ignoreFilter.some((e) => (e.pattern.startsWith("/") || e.pattern.startsWith("\\/")) && e.test(v))
|
||||
) {
|
||||
continue L1;
|
||||
}
|
||||
if (await this.plugin.$$isIgnoredByIgnoreFiles(v)) {
|
||||
|
||||
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: 89e825ef3b...3f3cf7d61d
41
src/main.ts
41
src/main.ts
@@ -18,6 +18,7 @@ import {
|
||||
type AUTO_MERGED,
|
||||
type RemoteDBSettings,
|
||||
type TweakValues,
|
||||
type CouchDBCredentials,
|
||||
} from "./lib/src/common/types.ts";
|
||||
import { type FileEventItem } from "./common/types.ts";
|
||||
import { type SimpleStore } from "./lib/src/common/utils.ts";
|
||||
@@ -51,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";
|
||||
@@ -81,6 +82,8 @@ 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.");
|
||||
@@ -117,7 +120,12 @@ export default class ObsidianLiveSyncPlugin
|
||||
}
|
||||
|
||||
// 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),
|
||||
@@ -276,16 +284,15 @@ export default class ObsidianLiveSyncPlugin
|
||||
|
||||
$$connectRemoteCouchDB(
|
||||
uri: string,
|
||||
auth: {
|
||||
username: string;
|
||||
password: string;
|
||||
},
|
||||
auth: CouchDBCredentials,
|
||||
disableRequestURI: boolean,
|
||||
passphrase: string | false,
|
||||
useDynamicIterationCount: boolean,
|
||||
performSetup: boolean,
|
||||
skipInfo: boolean,
|
||||
compression: boolean
|
||||
compression: boolean,
|
||||
customHeaders: Record<string, string>,
|
||||
useRequestAPI: boolean
|
||||
): Promise<
|
||||
| string
|
||||
| {
|
||||
@@ -398,6 +405,7 @@ export default class ObsidianLiveSyncPlugin
|
||||
6. localDatabase.onunload
|
||||
7. replicator.closeReplication
|
||||
8. localDatabase.close
|
||||
9. (event) EVENT_PLATFORM_UNLOADED
|
||||
|
||||
*/
|
||||
|
||||
@@ -537,8 +545,10 @@ export default class ObsidianLiveSyncPlugin
|
||||
$everyAfterResumeProcess(): Promise<boolean> {
|
||||
return InterceptiveEvery;
|
||||
}
|
||||
|
||||
$$askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
$$checkAndAskResolvingMismatchedTweaks(preferred: Partial<TweakValues>): Promise<[TweakValues | boolean, boolean]> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
$$askResolvingMismatchedTweaks(preferredSource: TweakValues): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
@@ -548,12 +558,21 @@ export default class ObsidianLiveSyncPlugin
|
||||
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();
|
||||
}
|
||||
|
||||
$everyOnDatabaseInitialized(showingNotice: boolean): Promise<boolean> {
|
||||
throwShouldBeOverridden();
|
||||
@@ -620,10 +639,6 @@ export default class ObsidianLiveSyncPlugin
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$waitForReplicationOnce(): Promise<boolean | void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
$$resetLocalDatabase(): Promise<void> {
|
||||
throwShouldBeOverridden();
|
||||
}
|
||||
|
||||
@@ -313,13 +313,7 @@ export class ModuleDatabaseFileAccess extends AbstractModule implements IObsidia
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -205,13 +205,10 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
): 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);
|
||||
@@ -275,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.
|
||||
@@ -300,7 +297,7 @@ export class ModuleFileHandler extends AbstractModule implements ICoreModule {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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";
|
||||
@@ -13,7 +13,7 @@ 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();
|
||||
|
||||
@@ -12,6 +12,7 @@ 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> {
|
||||
@@ -175,7 +176,7 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
}
|
||||
}
|
||||
}
|
||||
async fetchLocal(makeLocalChunkBeforeSync?: boolean) {
|
||||
async fetchLocal(makeLocalChunkBeforeSync?: boolean, preventMakeLocalFilesBeforeSync?: boolean) {
|
||||
await this.core.$allSuspendExtraSync();
|
||||
await this.askUseNewAdapter();
|
||||
this.core.settings.isConfigured = true;
|
||||
@@ -188,6 +189,10 @@ export class ModuleRebuilder extends AbstractModule implements ICoreModule, Rebu
|
||||
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);
|
||||
@@ -214,6 +219,7 @@ 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 (
|
||||
|
||||
@@ -18,29 +18,52 @@ import {
|
||||
type MetaEntry,
|
||||
} from "../../lib/src/common/types";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { getPath, isChunk, isValidPath, scheduleTask } from "../../common/utils";
|
||||
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 { EVENT_FILE_SAVED, EVENT_SETTING_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 {
|
||||
_replicatorType?: string;
|
||||
$everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
eventHub.onEvent(EVENT_FILE_SAVED, () => {
|
||||
if (this.settings.syncOnSave && !this.core.$$isSuspended()) {
|
||||
scheduleTask("perform-replicate-after-save", 250, () => this.core.$$waitForReplicationOnce());
|
||||
scheduleTask("perform-replicate-after-save", 250, () => this.core.$$replicateByEvent());
|
||||
}
|
||||
});
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (setting) => {
|
||||
if (this._replicatorType !== setting.remoteType) {
|
||||
void this.setReplicator();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
this._log($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (this.core.replicator) {
|
||||
await this.core.replicator.closeReplication();
|
||||
this._log("Replicator closed for changing", LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.core.replicator = replicator;
|
||||
this._replicatorType = this.settings.remoteType;
|
||||
await yieldMicrotask();
|
||||
return true;
|
||||
}
|
||||
@@ -61,137 +84,143 @@ export class ModuleReplicator extends AbstractModule implements ICoreModule {
|
||||
await this.loadQueuedFiles();
|
||||
return true;
|
||||
}
|
||||
|
||||
async $$replicate(showMessage: boolean = false): Promise<boolean | void> {
|
||||
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);
|
||||
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);
|
||||
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.$$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
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
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",
|
||||
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_DISMISS],
|
||||
CHOICE_DISMISS,
|
||||
10
|
||||
[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();
|
||||
@@ -215,25 +244,48 @@ 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 ids = [...new Set(JSON.parse(localStorage.getItem(lsKey) || "[]"))] as string[];
|
||||
const ids = [...new Set((await this.core.kvDB.get<string[]>(kvDBKey)) ?? [])];
|
||||
const batchSize = 100;
|
||||
const chunkedIds = arrayToChunkedArray(ids, batchSize);
|
||||
for await (const idsBatch of chunkedIds) {
|
||||
const ret = await this.localDatabase.allDocsRaw<EntryDoc>({
|
||||
keys: idsBatch,
|
||||
include_docs: true,
|
||||
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);
|
||||
if (errors.length > 0) {
|
||||
Logger("Some queued processes were not resurrected");
|
||||
Logger(JSON.stringify(errors), LOG_LEVEL_VERBOSE);
|
||||
try {
|
||||
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 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();
|
||||
}
|
||||
this.replicationResultProcessor.enqueueAll(docs);
|
||||
for await (const idsBatch of chunkedIds) {
|
||||
const ret = await this.localDatabase.allDocsRaw<EntryDoc>({
|
||||
keys: idsBatch,
|
||||
include_docs: true,
|
||||
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);
|
||||
if (errors.length > 0) {
|
||||
Logger("Some queued processes were not resurrected");
|
||||
Logger(JSON.stringify(errors), LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.replicationResultProcessor.enqueueAll(docs);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger(`Failed to load queued files.`, LOG_LEVEL_NOTICE);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
} finally {
|
||||
// Check again before awaiting,
|
||||
if (this.replicationResultProcessor.isSuspended) {
|
||||
this.replicationResultProcessor.resume();
|
||||
}
|
||||
}
|
||||
// Wait for all queued files to be processed.
|
||||
try {
|
||||
await this.replicationResultProcessor.waitForAllProcessed();
|
||||
} catch (e) {
|
||||
Logger(`Failed to wait for all queued files to be processed.`, LOG_LEVEL_NOTICE);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +366,7 @@ Or if you are sure know what had been happened, we can unlock the database from
|
||||
|
||||
// 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);
|
||||
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)}) `,
|
||||
@@ -379,7 +431,7 @@ Or if you are sure know what had been happened, we can unlock the database from
|
||||
): 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);
|
||||
Logger($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!sendChunksInBulkDisabled) {
|
||||
@@ -408,8 +460,4 @@ Or if you are sure know what had been happened, we can unlock the database from
|
||||
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,13 +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) {
|
||||
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
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);
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,24 @@ import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-w
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { sizeToHumanReadable } from "octagonal-wheels/number";
|
||||
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 = `We can set a maximum database capacity warning, **to take action before running out of space on the remote storage**.
|
||||
Do you want to enable this?
|
||||
|
||||
> [!MORE]-
|
||||
> - 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.
|
||||
|
||||
If we have reached the limit, we will be asked to enlarge the limit step by step.
|
||||
`;
|
||||
const ANSWER_0 = "No, never warn please";
|
||||
const ANSWER_800 = "800MB (Cloudant, fly.io)";
|
||||
const ANSWER_2000 = "2GB (Standard)";
|
||||
const ASK_ME_NEXT_TIME = "Ask me later";
|
||||
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");
|
||||
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[ANSWER_0, ANSWER_800, ANSWER_2000, ASK_ME_NEXT_TIME],
|
||||
{
|
||||
defaultAction: ASK_ME_NEXT_TIME,
|
||||
title: "Setting up database size notification",
|
||||
title: $msg("moduleCheckRemoteSize.titleDatabaseSizeNotify"),
|
||||
timeout: 40,
|
||||
}
|
||||
);
|
||||
@@ -51,40 +41,28 @@ If we have reached the limit, we will be asked to enlarge the limit step by step
|
||||
if (estimatedSize) {
|
||||
const maxSize = this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024;
|
||||
if (estimatedSize > maxSize) {
|
||||
const message = `**Your database is getting larger!** But do not worry, we can address it now. The time before running out of space on the remote storage.
|
||||
|
||||
| Measured size | Configured size |
|
||||
| --- | --- |
|
||||
| ${sizeToHumanReadable(estimatedSize)} | ${sizeToHumanReadable(maxSize)} |
|
||||
|
||||
> [!MORE]-
|
||||
> If you have been using it for many years, there may be unreferenced chunks - that is, garbage - accumulating in the database. Therefore, we recommend rebuilding everything. It will probably become much smaller.
|
||||
>
|
||||
> If the volume of your vault is simply increasing, it is better to rebuild everything after organizing the files. Self-hosted LiveSync does not delete the actual data even if you delete it to speed up the process. It is roughly [documented](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md).
|
||||
>
|
||||
> If you don't mind the increase, you can increase the notification limit by 100MB. This is the case if you are running it on your own server. However, it is better to rebuild everything from time to time.
|
||||
>
|
||||
|
||||
> [!WARNING]
|
||||
> If you perform rebuild everything, make sure all devices are synchronised. The plug-in will merge as much as possible, though.
|
||||
|
||||
\n`;
|
||||
const message = $msg("moduleCheckRemoteSize.msgDatabaseGrowing", {
|
||||
estimatedSize: sizeToHumanReadable(estimatedSize),
|
||||
maxSize: sizeToHumanReadable(maxSize),
|
||||
});
|
||||
const newMax = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
const ANSWER_ENLARGE_LIMIT = `increase to ${newMax}MB`;
|
||||
const ANSWER_REBUILD = "Rebuild Everything Now";
|
||||
const ANSWER_IGNORE = "Dismiss";
|
||||
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: "Remote storage size exceeded the limit",
|
||||
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?",
|
||||
$msg("moduleCheckRemoteSize.msgConfirmRebuild"),
|
||||
{ defaultOption: "No" }
|
||||
);
|
||||
if (ret == "yes") {
|
||||
@@ -95,7 +73,9 @@ If we have reached the limit, we will be asked to enlarge the limit step by step
|
||||
} else if (ret == ANSWER_ENLARGE_LIMIT) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
this._log(
|
||||
`Threshold has been enlarged to ${this.settings.notifyThresholdOfRemoteStorageSize}MB`,
|
||||
$msg("moduleCheckRemoteSize.logThresholdEnlarged", {
|
||||
size: this.settings.notifyThresholdOfRemoteStorageSize.toString(),
|
||||
}),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await this.core.saveSettings();
|
||||
@@ -104,11 +84,21 @@ If we have reached the limit, we will be asked to enlarge the limit step by step
|
||||
}
|
||||
|
||||
this._log(
|
||||
`Remote storage size: ${sizeToHumanReadable(estimatedSize)} exceeded ${sizeToHumanReadable(this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024)} `,
|
||||
$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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,13 +44,16 @@ export class ModuleConflictResolver extends AbstractModule implements ICoreModul
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
eventHub.emitEvent("conflict-cancelled", path);
|
||||
this._log(`${title} Conflicted revision deleted ${displayRev(deleteRevision)} ${path}`, LOG_LEVEL_INFO);
|
||||
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;
|
||||
}
|
||||
this._log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
|
||||
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.
|
||||
@@ -58,7 +61,8 @@ export class ModuleConflictResolver extends AbstractModule implements ICoreModul
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -108,7 +112,9 @@ export class ModuleConflictResolver extends AbstractModule implements ICoreModul
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
].join(",");
|
||||
]
|
||||
.filter((e) => e.trim())
|
||||
.join(",");
|
||||
return await this.core.$$resolveConflictByDeletingRev(path, loser.rev, subTitle);
|
||||
}
|
||||
// make diff.
|
||||
@@ -140,7 +146,7 @@ export class ModuleConflictResolver extends AbstractModule implements ICoreModul
|
||||
//auto resolved, but need check again;
|
||||
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");
|
||||
await this.core.$$queueConflictCheck(filename);
|
||||
@@ -192,7 +198,7 @@ export class ModuleConflictResolver extends AbstractModule implements ICoreModul
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
console.warn(mTimeAndRev);
|
||||
// console.warn(mTimeAndRev);
|
||||
this._log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} 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) {
|
||||
@@ -105,16 +106,35 @@ export class ModuleRedFlag extends AbstractModule implements ICoreModule {
|
||||
`${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?
|
||||
> [!MORE]-
|
||||
> If creating local chunks before fetching, only the difference between the local and remote will be fetched.
|
||||
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);
|
||||
|
||||
`,
|
||||
{ defaultOption: "Yes", title: "Trick to transfer efficiently" }
|
||||
)) == "yes";
|
||||
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync);
|
||||
await this.deleteRedFlag3();
|
||||
if (this.settings.suspendFileWatching) {
|
||||
if (
|
||||
|
||||
@@ -2,91 +2,143 @@ import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import {
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
CompatibilityBreakingTweakValues,
|
||||
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);
|
||||
@@ -119,71 +171,7 @@ Please select which one you want to use.
|
||||
if (await replicator.tryConnectRemote(trialSetting)) {
|
||||
const preferred = await replicator.getRemotePreferredTweakValues(trialSetting);
|
||||
if (preferred) {
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
// Making tables:
|
||||
let table = `| Value name | This device | Stored | \n` + `|: --- |: ---- :|: ---- :| \n`;
|
||||
let differenceCount = 0;
|
||||
// const items = [mine,preferred]
|
||||
for (const v of items) {
|
||||
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
|
||||
const valuePreferred = escapeMarkdownValue(preferred[key]);
|
||||
const currentDisp = `${escapeMarkdownValue((trialSetting as TweakValues)?.[key])} |`;
|
||||
if ((trialSetting as TweakValues)?.[key] !== preferred[key]) {
|
||||
if (CompatibilityBreakingTweakValues.indexOf(key) !== -1) {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
table += `| ${confName(key)} | ${currentDisp} ${valuePreferred} | \n`;
|
||||
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
|
||||
? `
|
||||
|
||||
>[!WARNING]
|
||||
> Some remote configurations are not compatible with the local database of this device. Rebuilding the local database will be required.
|
||||
***Please ensure that you have time and are connected to a stable network to apply!***`
|
||||
: "";
|
||||
|
||||
const message = `
|
||||
The settings in the remote database are as follows.
|
||||
If you want to use these settings, please select "Use configured".
|
||||
If you want to keep the settings of this device, please select "Dismiss".
|
||||
|
||||
${table}
|
||||
|
||||
>[!TIP]
|
||||
> If you want to synchronise all settings, please use \`Sync settings via markdown\` after applying minimal configuration with this feature.
|
||||
|
||||
${additionalMessage}`;
|
||||
|
||||
const CHOICE_USE_REMOTE = "Use configured";
|
||||
const CHOICE_DISMISS = "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: "Use Remote Configuration",
|
||||
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 await this.$$askUseRemoteConfiguration(trialSetting, preferred);
|
||||
} else {
|
||||
this._log("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
@@ -193,4 +181,95 @@ ${additionalMessage}`;
|
||||
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,4 +1,4 @@
|
||||
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";
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
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";
|
||||
import { createBlob, type CustomRegExp } from "../../lib/src/common/utils";
|
||||
|
||||
export class ModuleFileAccessObsidian extends AbstractObsidianModule implements IObsidianModule, StorageAccess {
|
||||
vaultAccess!: SerializedFileAccess;
|
||||
@@ -60,7 +60,23 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
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;
|
||||
@@ -158,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
|
||||
@@ -206,8 +223,8 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
|
||||
async getFilesIncludeHidden(
|
||||
basePath: string,
|
||||
includeFilter?: RegExp[],
|
||||
excludeFilter?: RegExp[],
|
||||
includeFilter?: CustomRegExp[],
|
||||
excludeFilter?: CustomRegExp[],
|
||||
skipFolder: string[] = [".git", ".trash", "node_modules"]
|
||||
): Promise<FilePath[]> {
|
||||
let w: ListedFiles;
|
||||
@@ -222,16 +239,11 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
|
||||
let files = [] as string[];
|
||||
for (const file of w.files) {
|
||||
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;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (includeFilter && includeFilter.length > 0) {
|
||||
if (!includeFilter.some((e) => e.test(file))) continue;
|
||||
}
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some((e) => file.match(e))) continue;
|
||||
if (excludeFilter && excludeFilter.some((ee) => ee.test(file))) {
|
||||
continue;
|
||||
}
|
||||
if (await this.plugin.$$isIgnoredByIgnoreFiles(file)) continue;
|
||||
files.push(file);
|
||||
@@ -242,25 +254,21 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements
|
||||
if (skipFolder.some((e) => folderName === e)) {
|
||||
continue;
|
||||
}
|
||||
if (excludeFilter && excludeFilter.some((e) => v.match(e))) {
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some((e) => v.match(e))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeFilter) {
|
||||
if (!includeFilter.some((e) => v.match(e))) continue;
|
||||
if (excludeFilter && excludeFilter.some((e) => e.test(v))) {
|
||||
continue;
|
||||
}
|
||||
if (await this.plugin.$$isIgnoredByIgnoreFiles(v)) {
|
||||
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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// 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";
|
||||
@@ -9,7 +10,9 @@ import {
|
||||
confirmWithMessageWithWideButton,
|
||||
} from "./UILib/dialogs.ts";
|
||||
import { Notice } from "../../deps.ts";
|
||||
import type { Confirm } from "../interfaces/Confirm.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.
|
||||
@@ -17,6 +20,7 @@ import type { Confirm } from "../interfaces/Confirm.ts";
|
||||
export class ModuleInputUIObsidian extends AbstractObsidianModule implements IObsidianModule, Confirm {
|
||||
$everyOnload(): Promise<boolean> {
|
||||
this.core.confirm = this;
|
||||
setConfirmInstance(this);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -31,29 +35,34 @@ export class ModuleInputUIObsidian extends AbstractObsidianModule implements IOb
|
||||
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 || "Confirmation",
|
||||
opt.title || defaultTitle,
|
||||
message,
|
||||
["Yes", "No"],
|
||||
opt.defaultOption ?? "No",
|
||||
[yesLabel, noLabel],
|
||||
defaultOption,
|
||||
opt.timeout
|
||||
);
|
||||
return ret == "Yes" ? "yes" : "no";
|
||||
return ret === yesLabel ? "yes" : "no";
|
||||
}
|
||||
|
||||
askSelectString(message: string, items: string[]): Promise<string> {
|
||||
return askSelectString(this.app, message, items);
|
||||
}
|
||||
|
||||
askSelectStringDialogue(
|
||||
askSelectStringDialogue<T extends readonly string[]>(
|
||||
message: string,
|
||||
buttons: string[],
|
||||
opt: { title?: string; defaultAction: (typeof buttons)[number]; timeout?: number }
|
||||
): Promise<(typeof buttons)[number] | false> {
|
||||
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 || "Select",
|
||||
opt.title || defaultTitle,
|
||||
message,
|
||||
buttons,
|
||||
opt.defaultAction,
|
||||
@@ -91,6 +100,7 @@ export class ModuleInputUIObsidian extends AbstractObsidianModule implements IOb
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
confirmWithMessage(
|
||||
title: string,
|
||||
contentMd: string,
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { ButtonComponent } from "obsidian";
|
||||
import { App, FuzzySuggestModal, MarkdownRenderer, Modal, Plugin, Setting } from "../../../deps.ts";
|
||||
import { EVENT_PLUGIN_UNLOADED, eventHub } from "../../../common/events.ts";
|
||||
import { delay } from "octagonal-wheels/promises";
|
||||
|
||||
class AutoClosableModal extends Modal {
|
||||
removeEvent: (() => void) | undefined;
|
||||
_closeByUnload() {
|
||||
eventHub.off(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
this.close();
|
||||
}
|
||||
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
this.removeEvent = eventHub.onEvent(EVENT_PLUGIN_UNLOADED, async () => {
|
||||
await delay(100);
|
||||
if (!this.removeEvent) return;
|
||||
this.close();
|
||||
this.removeEvent = undefined;
|
||||
});
|
||||
this._closeByUnload = this._closeByUnload.bind(this);
|
||||
eventHub.once(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
}
|
||||
onClose() {
|
||||
if (this.removeEvent) {
|
||||
this.removeEvent();
|
||||
this.removeEvent = undefined;
|
||||
}
|
||||
eventHub.off(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +130,11 @@ export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageBox extends AutoClosableModal {
|
||||
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;
|
||||
@@ -154,11 +149,11 @@ export class MessageBox extends AutoClosableModal {
|
||||
plugin: Plugin,
|
||||
title: string,
|
||||
contentMd: string,
|
||||
buttons: string[],
|
||||
defaultAction: (typeof buttons)[number],
|
||||
buttons: T,
|
||||
defaultAction: T[number],
|
||||
timeout: number | undefined,
|
||||
wideButton: boolean,
|
||||
onSubmit: (result: (typeof buttons)[number] | false) => void
|
||||
onSubmit: (result: T[number] | false) => void
|
||||
) {
|
||||
super(plugin.app);
|
||||
this.plugin = plugin;
|
||||
@@ -194,6 +189,7 @@ export class MessageBox extends AutoClosableModal {
|
||||
this.titleEl.setText(this.title);
|
||||
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();
|
||||
@@ -262,14 +258,14 @@ export class MessageBox extends AutoClosableModal {
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmWithMessage(
|
||||
export function confirmWithMessage<T extends readonly string[]>(
|
||||
plugin: Plugin,
|
||||
title: string,
|
||||
contentMd: string,
|
||||
buttons: string[],
|
||||
defaultAction: (typeof buttons)[number],
|
||||
buttons: T,
|
||||
defaultAction: T[number],
|
||||
timeout?: number
|
||||
): Promise<(typeof buttons)[number] | false> {
|
||||
): Promise<T[number] | false> {
|
||||
return new Promise((res) => {
|
||||
const dialog = new MessageBox(plugin, title, contentMd, buttons, defaultAction, timeout, false, (result) =>
|
||||
res(result)
|
||||
@@ -277,14 +273,14 @@ export function confirmWithMessage(
|
||||
dialog.open();
|
||||
});
|
||||
}
|
||||
export function confirmWithMessageWithWideButton(
|
||||
export function confirmWithMessageWithWideButton<T extends readonly string[]>(
|
||||
plugin: Plugin,
|
||||
title: string,
|
||||
contentMd: string,
|
||||
buttons: string[],
|
||||
defaultAction: (typeof buttons)[number],
|
||||
buttons: T,
|
||||
defaultAction: T[number],
|
||||
timeout?: number
|
||||
): Promise<(typeof buttons)[number] | false> {
|
||||
): Promise<T[number] | false> {
|
||||
return new Promise((res) => {
|
||||
const dialog = new MessageBox(plugin, title, contentMd, buttons, defaultAction, timeout, true, (result) =>
|
||||
res(result)
|
||||
|
||||
@@ -199,9 +199,15 @@ export class SerializedFileAccess {
|
||||
|
||||
touchedFiles: string[] = [];
|
||||
|
||||
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}`;
|
||||
_statInternal(file: FilePath) {
|
||||
return this.app.vault.adapter.stat(file);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type UXFileInfoStub,
|
||||
type UXInternalFileInfoStub,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import { delay, fireAndForget } from "../../../lib/src/common/utils.ts";
|
||||
import { delay, fireAndForget, getFileRegExp } 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 {
|
||||
@@ -161,12 +161,10 @@ export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
if (!this.plugin.settings.syncInternalFiles && !this.plugin.settings.usePluginSync) return;
|
||||
if (!this.plugin.settings.watchInternalFileChanges) return;
|
||||
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;
|
||||
const ignorePatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
|
||||
const targetPatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
|
||||
if (ignorePatterns.some((e) => e.test(path))) return;
|
||||
if (!targetPatterns.some((e) => e.test(path))) return;
|
||||
if (path.endsWith("/")) {
|
||||
// Folder
|
||||
return;
|
||||
|
||||
@@ -81,9 +81,9 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
|
||||
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(
|
||||
@@ -180,7 +180,7 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
};
|
||||
initProcess.push(
|
||||
runAll("UPDATE DATABASE", filesExistOnlyInStorage, async (e) => {
|
||||
// console.warn("UPDATE DATABASE", e);
|
||||
// Exists in storage but not in database.
|
||||
const file = storageFileNameMap[storageFileNameCI2CS[e]];
|
||||
if (!this.core.$$isFileSizeExceeded(file.stat.size)) {
|
||||
const path = file.path;
|
||||
@@ -195,9 +195,15 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
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);
|
||||
@@ -229,13 +235,13 @@ export class ModuleInitializerFile extends AbstractModule implements ICoreModule
|
||||
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);
|
||||
// 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`,
|
||||
|
||||
@@ -1,200 +1,313 @@
|
||||
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 { 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";
|
||||
|
||||
const URI_DOC = "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use";
|
||||
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 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;
|
||||
}
|
||||
// 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("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);
|
||||
}
|
||||
// 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(`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.
|
||||
// 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;
|
||||
// }
|
||||
// }
|
||||
|
||||
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();
|
||||
}
|
||||
// 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 = `Your device has **not been set up yet**. Let me guide you through the setup process.
|
||||
|
||||
Please keep in mind that every dialogue content can be copied to the clipboard. If you need to refer to it later, you can paste it into a note in Obsidian. You can also translate it into your language using a translation tool.
|
||||
|
||||
First, do you have **Setup URI**?
|
||||
|
||||
Note: If you do not know what it is, please refer to the [documentation](${URI_DOC}).
|
||||
`;
|
||||
|
||||
const USE_SETUP = "Yes, I have";
|
||||
const NEXT = "No, I do not have";
|
||||
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: "Welcome to Self-hosted LiveSync",
|
||||
title: $msg("moduleMigration.titleWelcome"),
|
||||
defaultAction: USE_SETUP,
|
||||
});
|
||||
if (ret === USE_SETUP) {
|
||||
@@ -207,23 +320,24 @@ Note: If you do not know what it is, please refer to the [documentation](${URI_D
|
||||
}
|
||||
|
||||
async askAgainForSetupURI() {
|
||||
const message = `We strongly recommend that you generate a set-up URI and use it.
|
||||
If you do not have knowledge about it, please refer to the [documentation](${URI_DOC}) (Sorry again, but it is important).
|
||||
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");
|
||||
|
||||
How do you want to set it up manually?`;
|
||||
|
||||
const USE_MINIMAL = "Take me into the setup wizard";
|
||||
const USE_SETUP = "Set it up all manually";
|
||||
const NEXT = "Remind me at the next launch";
|
||||
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message, [USE_MINIMAL, USE_SETUP, NEXT], {
|
||||
title: "Recommendation to use Setup URI",
|
||||
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 (ret === USE_P2P) {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P);
|
||||
return false;
|
||||
}
|
||||
if (ret === USE_SETUP) {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTINGS);
|
||||
return false;
|
||||
@@ -235,23 +349,28 @@ How do you want to set it up manually?`;
|
||||
|
||||
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
|
||||
if (!(await this.initialMessage()) || !(await this.askAgainForSetupURI())) {
|
||||
this._log(
|
||||
"The setup has been cancelled, Self-hosted LiveSync waiting for your setup!",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
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,7 +1,7 @@
|
||||
import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
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 EntryDoc, type FilePathWithPrefix } from "../../lib/src/common/types.ts";
|
||||
import { type CouchDBCredentials, type EntryDoc, type FilePathWithPrefix } from "../../lib/src/common/types.ts";
|
||||
import { getPathFromTFile } from "../../common/utils.ts";
|
||||
import {
|
||||
disableEncryption,
|
||||
@@ -13,30 +13,19 @@ import {
|
||||
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 { AuthorizationHeaderGenerator } from "../../lib/src/replication/httplib.ts";
|
||||
|
||||
setNoticeClass(Notice);
|
||||
|
||||
async function fetchByAPI(request: RequestUrlParam): Promise<RequestUrlResponse> {
|
||||
const ret = await requestUrl(request);
|
||||
if (ret.status - (ret.status % 100) !== 200) {
|
||||
const er: Error & { status?: number } = new Error(`Request Error:${ret.status}`);
|
||||
if (ret.json) {
|
||||
er.message = ret.json.reason;
|
||||
er.name = `${ret.json.error ?? ""}:${ret.json.message ?? ""}`;
|
||||
}
|
||||
er.status = ret.status;
|
||||
throw er;
|
||||
}
|
||||
async function fetchByAPI(request: RequestUrlParam, errorAsResult = false): Promise<RequestUrlResponse> {
|
||||
const ret = await requestUrl({ ...request, throw: !errorAsResult });
|
||||
return ret;
|
||||
}
|
||||
|
||||
export class ModuleObsidianAPI extends AbstractObsidianModule implements IObsidianModule {
|
||||
_customHandler!: ObsHttpHandler;
|
||||
authHeaderSource = reactiveSource<string>("");
|
||||
authHeader = reactive(() =>
|
||||
this.authHeaderSource.value == "" ? "" : "Basic " + window.btoa(this.authHeaderSource.value)
|
||||
);
|
||||
|
||||
_authHeader = new AuthorizationHeaderGenerator();
|
||||
|
||||
last_successful_post = false;
|
||||
$$customFetchHandler(): ObsHttpHandler {
|
||||
@@ -47,31 +36,86 @@ export class ModuleObsidianAPI extends AbstractObsidianModule implements IObsidi
|
||||
return !this.last_successful_post;
|
||||
}
|
||||
|
||||
async _fetchByAPI(url: 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:
|
||||
transformedHeaders?.["content-type"] ?? transformedHeaders?.["Content-Type"] ?? "application/json",
|
||||
};
|
||||
const r = await fetchByAPI(requestParam, true);
|
||||
return new Response(r.arrayBuffer, {
|
||||
headers: r.headers,
|
||||
status: r.status,
|
||||
statusText: `${r.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchByAPI(
|
||||
url: string,
|
||||
localURL: string,
|
||||
method: string,
|
||||
authHeader: string,
|
||||
opts?: RequestInit
|
||||
): Promise<Response> {
|
||||
const body = opts?.body as string;
|
||||
const size = body ? ` (${body.length})` : "";
|
||||
try {
|
||||
const r = await this._fetchByAPI(url, authHeader, opts);
|
||||
this.plugin.requestCount.value = this.plugin.requestCount.value + 1;
|
||||
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 r;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
async $$connectRemoteCouchDB(
|
||||
uri: string,
|
||||
auth: { username: string; password: string },
|
||||
auth: CouchDBCredentials,
|
||||
disableRequestURI: boolean,
|
||||
passphrase: string | false,
|
||||
useDynamicIterationCount: boolean,
|
||||
performSetup: boolean,
|
||||
skipInfo: boolean,
|
||||
compression: boolean
|
||||
compression: boolean,
|
||||
customHeaders: Record<string, string>,
|
||||
useRequestAPI: boolean
|
||||
): 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;
|
||||
}
|
||||
const authHeader = this.authHeader.value;
|
||||
// const _this = this;
|
||||
// let authHeader = await this._authHeader.getAuthorizationHeader(auth);
|
||||
|
||||
const conf: PouchDB.HttpAdapter.HttpAdapterConfiguration = {
|
||||
adapter: "http",
|
||||
auth,
|
||||
auth: "username" in auth ? auth : undefined,
|
||||
skip_setup: !performSetup,
|
||||
fetch: async (url: string | Request, opts?: RequestInit) => {
|
||||
const authHeader = await this._authHeader.getAuthorizationHeader(auth);
|
||||
let size = "";
|
||||
const localURL = url.toString().substring(uri.length);
|
||||
const method = opts?.method ?? "GET";
|
||||
@@ -87,88 +131,86 @@ export class ModuleObsidianAPI extends AbstractObsidianModule implements IObsidi
|
||||
}
|
||||
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 {
|
||||
const headers = new Headers(opts?.headers);
|
||||
if (customHeaders) {
|
||||
for (const [key, value] of Object.entries(customHeaders)) {
|
||||
if (key && value) {
|
||||
headers.append(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!("username" in auth)) {
|
||||
headers.append("authorization", authHeader);
|
||||
}
|
||||
|
||||
try {
|
||||
this.plugin.requestCount.value = this.plugin.requestCount.value + 1;
|
||||
const r = await fetchByAPI(requestParam);
|
||||
const response: Response = await (useRequestAPI
|
||||
? this._fetchByAPI(url.toString(), authHeader, { ...opts, headers })
|
||||
: fetch(url, { ...opts, headers }));
|
||||
if (method == "POST" || method == "PUT") {
|
||||
this.last_successful_post = r.status - (r.status % 100) == 200;
|
||||
this.last_successful_post = response.ok;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
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.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 (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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._log(
|
||||
`Just checkpoint or some server information has been missing. The 404 error shown above is not an error.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
return response;
|
||||
} catch (ex) {
|
||||
if (ex instanceof TypeError) {
|
||||
if (useRequestAPI) {
|
||||
this._log("Failed to request by API.");
|
||||
throw ex;
|
||||
}
|
||||
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,
|
||||
headers,
|
||||
});
|
||||
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.last_successful_post = false;
|
||||
@@ -178,6 +220,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule implements IObsidi
|
||||
} finally {
|
||||
this.plugin.responseCount.value = this.plugin.responseCount.value + 1;
|
||||
}
|
||||
|
||||
// return await fetch(url, opts);
|
||||
},
|
||||
};
|
||||
@@ -195,11 +238,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;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
} else {
|
||||
if (this.settings.syncOnEditorSave) {
|
||||
this._log("Sync on Editor Save.", LOG_LEVEL_VERBOSE);
|
||||
fireAndForget(() => this.core.$$replicate());
|
||||
fireAndForget(() => this.core.$$replicateByEvent());
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -155,7 +155,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule implements IObs
|
||||
return;
|
||||
}
|
||||
if (this.settings.syncOnFileOpen && !this.core.$$isSuspended()) {
|
||||
await this.core.$$replicate();
|
||||
await this.core.$$replicateByEvent();
|
||||
}
|
||||
await this.core.$$queueConflictCheckIfOpen(file.path as FilePathWithPrefix);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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> {
|
||||
@@ -16,7 +17,7 @@ export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsid
|
||||
</g>`
|
||||
);
|
||||
|
||||
this.addRibbonIcon("replicate", "Replicate", async () => {
|
||||
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await this.core.$$replicate(true);
|
||||
}).addClass("livesync-ribbon-replicate");
|
||||
|
||||
@@ -105,7 +106,6 @@ export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsid
|
||||
}
|
||||
$everyOnload(): Promise<boolean> {
|
||||
this.app.workspace.onLayoutReady(this.core.$$onLiveSyncReady.bind(this.core));
|
||||
// eslint-disable-next-line no-unused-labels
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export class ModuleObsidianMenu extends AbstractObsidianModule implements IObsid
|
||||
});
|
||||
}
|
||||
if (leaves.length > 0) {
|
||||
this.app.workspace.revealLeaf(leaves[0]);
|
||||
await this.app.workspace.revealLeaf(leaves[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +6,7 @@ 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> {
|
||||
@@ -36,7 +37,6 @@ export class ModuleDev extends AbstractObsidianModule implements IObsidianModule
|
||||
|
||||
$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) => {
|
||||
void this.onMissingTranslation(key);
|
||||
@@ -99,9 +99,41 @@ export class ModuleDev extends AbstractObsidianModule implements IObsidianModule
|
||||
}
|
||||
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][]>([]);
|
||||
|
||||
@@ -2,11 +2,12 @@ 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";
|
||||
import { getFileRegExp } from "../../lib/src/common/utils.ts";
|
||||
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
@@ -160,8 +161,13 @@ 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))) {
|
||||
continue;
|
||||
@@ -195,14 +201,12 @@ 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"));
|
||||
const ignorePatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
|
||||
const targetPatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
|
||||
const out = [] as any[];
|
||||
const files = await this.core.storageAccess.getFilesIncludeHidden("", undefined, ignorePatterns);
|
||||
console.dir(files);
|
||||
const files = await this.core.storageAccess.getFilesIncludeHidden("", targetPatterns, ignorePatterns);
|
||||
// console.dir(files);
|
||||
const webcrypto = await getWebCrypto();
|
||||
for (const file of files) {
|
||||
// if (!await this.core.$$isTargetFile(file)) {
|
||||
// continue;
|
||||
|
||||
@@ -30,7 +30,6 @@ 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,
|
||||
@@ -42,7 +41,6 @@ export class TestPaneView extends ItemView {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
async onClose() {
|
||||
this.component?.$destroy();
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -66,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;
|
||||
|
||||
@@ -94,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]}`;
|
||||
}
|
||||
}
|
||||
@@ -104,9 +110,13 @@
|
||||
}
|
||||
if (rev == docA._rev) {
|
||||
if (checkStorageDiff) {
|
||||
const isExist = await plugin.storageAccess.isExistsIncludeHidden(stripAllPrefixes(getPath(docA)));
|
||||
const isExist = await plugin.storageAccess.isExistsIncludeHidden(
|
||||
stripAllPrefixes(getPath(docA))
|
||||
);
|
||||
if (isExist) {
|
||||
const data = await plugin.storageAccess.readHiddenFileBinary(stripAllPrefixes(getPath(docA)));
|
||||
const data = await plugin.storageAccess.readHiddenFileBinary(
|
||||
stripAllPrefixes(getPath(docA))
|
||||
);
|
||||
const d = readAsBlob(doc);
|
||||
const result = await isDocContentSame(data, d);
|
||||
if (result) {
|
||||
@@ -187,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>
|
||||
@@ -212,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}
|
||||
@@ -257,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,10 +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 {
|
||||
component?: GlobalHistoryComponent;
|
||||
export class GlobalHistoryView extends SvelteItemView {
|
||||
instantiateComponent(target: HTMLElement) {
|
||||
return mount(GlobalHistoryComponent, {
|
||||
target: target,
|
||||
props: {
|
||||
plugin: this.plugin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon = "clock";
|
||||
title: string = "";
|
||||
@@ -26,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,83 +1,105 @@
|
||||
<script lang="ts">
|
||||
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 { 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 () => {
|
||||
const _logMessages = reactive(() => logMessages.value);
|
||||
_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,14 +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 {
|
||||
component?: LogPaneComponent;
|
||||
export class LogPaneView extends SvelteItemView {
|
||||
instantiateComponent(target: HTMLElement) {
|
||||
return mount(LogPaneComponent, {
|
||||
target: target,
|
||||
props: {
|
||||
close: () => {
|
||||
this.leaf.detach();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon = "view-log";
|
||||
title: string = "";
|
||||
navigation = true;
|
||||
navigation = false;
|
||||
|
||||
getIcon(): string {
|
||||
return "view-log";
|
||||
@@ -24,19 +37,7 @@ export class LogPaneView extends ItemView {
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule im
|
||||
// So we have to run replication if configured.
|
||||
// TODO: Make this is as a event request
|
||||
if (this.settings.syncAfterMerge && !this.core.$$isSuspended()) {
|
||||
await this.core.$$waitForReplicationOnce();
|
||||
await this.core.$$replicateByEvent();
|
||||
}
|
||||
// And, check it again.
|
||||
await this.core.$$queueConflictCheck(filename);
|
||||
|
||||
@@ -26,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.
|
||||
|
||||
@@ -61,7 +63,9 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
|
||||
statusBarLabels!: ReactiveValue<{ message: string; status: string }>;
|
||||
statusLog = reactiveSource("");
|
||||
activeFileStatus = reactiveSource("");
|
||||
notifies: { [key: string]: { notice: Notice; count: number } } = {};
|
||||
p2pLogCollector = new P2PLogCollector();
|
||||
|
||||
observeForLogs() {
|
||||
const padSpaces = `\u{2007}`.repeat(10);
|
||||
@@ -167,19 +171,22 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
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.core.$$isReloadingScheduled()
|
||||
? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n`
|
||||
: "";
|
||||
const { message } = statusLineLabel();
|
||||
const fileStatus = this.activeFileStatus.value;
|
||||
const status = scheduleMessage + this.statusLog.value;
|
||||
|
||||
const fileStatusIcon = `${fileStatus && this.settings.hideFileWarningNotice ? " ⛔ SKIP" : ""}`;
|
||||
return {
|
||||
message,
|
||||
message: `${message}${fileStatusIcon}`,
|
||||
status,
|
||||
};
|
||||
});
|
||||
@@ -195,6 +202,7 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
$everyOnload(): Promise<boolean> {
|
||||
eventHub.onEvent(EVENT_LEAF_ACTIVE_CHANGED, () => this.onActiveLeafChange());
|
||||
eventHub.onceEvent(EVENT_LAYOUT_READY, () => this.onActiveLeafChange());
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
adjustStatusDivPosition() {
|
||||
@@ -223,7 +231,9 @@ export class ModuleLog extends AbstractObsidianModule implements IObsidianModule
|
||||
return "";
|
||||
}
|
||||
async setFileStatus() {
|
||||
this.messageArea!.innerText = await this.getActiveFileStatus();
|
||||
const fileStatus = await this.getActiveFileStatus();
|
||||
this.activeFileStatus.value = fileStatus;
|
||||
this.messageArea!.innerText = this.settings.hideFileWarningNotice ? "" : fileStatus;
|
||||
}
|
||||
onActiveLeafChange() {
|
||||
fireAndForget(async () => {
|
||||
@@ -292,7 +302,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");
|
||||
|
||||
|
||||
@@ -11,9 +11,45 @@ import {
|
||||
} 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 { $msg, setLang } from "../../lib/src/common/i18n";
|
||||
import { isCloudantURI } from "../../lib/src/pouchdb/utils_couchdb";
|
||||
import { getLanguage } from "obsidian";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "../../lib/src/common/rosetta.ts";
|
||||
export class ModuleObsidianSettings extends AbstractObsidianModule implements IObsidianModule {
|
||||
async $everyOnLayoutReady(): Promise<boolean> {
|
||||
let isChanged = false;
|
||||
if (this.settings.displayLanguage == "") {
|
||||
const obsidianLanguage = getLanguage();
|
||||
if (
|
||||
SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported
|
||||
obsidianLanguage != this.settings.displayLanguage && // Check if the language is different from the current setting
|
||||
this.settings.displayLanguage != ""
|
||||
) {
|
||||
// Check if the current setting is not empty (Means migrated or installed).
|
||||
this.settings.displayLanguage = obsidianLanguage as I18N_LANGS;
|
||||
isChanged = true;
|
||||
setLang(this.settings.displayLanguage);
|
||||
} else if (this.settings.displayLanguage == "") {
|
||||
this.settings.displayLanguage = "def";
|
||||
setLang(this.settings.displayLanguage);
|
||||
await this.core.$$saveSettingData();
|
||||
}
|
||||
}
|
||||
if (isChanged) {
|
||||
const revert = $msg("dialog.yourLanguageAvailable.btnRevertToDefault");
|
||||
if (
|
||||
(await this.core.confirm.askSelectStringDialogue($msg(`dialog.yourLanguageAvailable`), ["OK", revert], {
|
||||
defaultAction: "OK",
|
||||
title: $msg(`dialog.yourLanguageAvailable.Title`),
|
||||
})) == revert
|
||||
) {
|
||||
this.settings.displayLanguage = "def";
|
||||
setLang(this.settings.displayLanguage);
|
||||
}
|
||||
await this.core.$$saveSettingData();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
getPassphrase(settings: ObsidianLiveSyncSettings) {
|
||||
const methods: Record<ConfigPassphraseStore, () => Promise<string | false>> = {
|
||||
"": () => Promise.resolve("*"),
|
||||
@@ -94,6 +130,16 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
region: settings.region,
|
||||
secretKey: settings.secretKey,
|
||||
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,
|
||||
useRequestAPI: settings.useRequestAPI,
|
||||
bucketPrefix: settings.bucketPrefix,
|
||||
};
|
||||
settings.encryptedCouchDBConnection = await this.encryptConfigurationItem(
|
||||
JSON.stringify(connectionSetting),
|
||||
@@ -191,6 +237,7 @@ export class ModuleObsidianSettings extends AbstractObsidianModule implements IO
|
||||
}
|
||||
}
|
||||
this.settings = settings;
|
||||
|
||||
setLang(this.settings.displayLanguage);
|
||||
|
||||
if ("workingEncrypt" in this.settings) delete this.settings.workingEncrypt;
|
||||
|
||||
@@ -191,6 +191,11 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractObsidianModule imp
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
KeyIndexOfSettings,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { configURIBase } from "../../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",
|
||||
@@ -42,9 +59,51 @@ 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];
|
||||
if (index < 0) {
|
||||
// This setting should be ignored.
|
||||
continue;
|
||||
}
|
||||
settingArr[index] = settingValue;
|
||||
}
|
||||
const w = encodeAnyArray(settingArr);
|
||||
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 < 0) {
|
||||
// This setting should be ignored.
|
||||
continue;
|
||||
}
|
||||
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",
|
||||
@@ -74,7 +133,7 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi
|
||||
const encryptedSetting = encodeURIComponent(
|
||||
await encrypt(JSON.stringify(setting), encryptingPassphrase, false)
|
||||
);
|
||||
const uri = `${configURIBase}${encryptedSetting}`;
|
||||
const uri = `${configURIBase}${encryptedSetting} `;
|
||||
await navigator.clipboard.writeText(uri);
|
||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
@@ -95,7 +154,7 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi
|
||||
const encryptedSetting = encodeURIComponent(
|
||||
await encrypt(JSON.stringify(setting), encryptingPassphrase, false)
|
||||
);
|
||||
const uri = `${configURIBase}${encryptedSetting}`;
|
||||
const uri = `${configURIBase}${encryptedSetting} `;
|
||||
await navigator.clipboard.writeText(uri);
|
||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
@@ -103,16 +162,155 @@ 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`);
|
||||
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));
|
||||
@@ -125,133 +323,7 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi
|
||||
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 = "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.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 =
|
||||
"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;
|
||||
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);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
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;
|
||||
@@ -86,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)}`;
|
||||
@@ -216,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;
|
||||
@@ -268,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();
|
||||
@@ -363,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);
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
<script lang="ts">
|
||||
export let patterns = [] as string[];
|
||||
export let originals = [] as string[];
|
||||
import type { CustomRegExpSource } from "../../../lib/src/common/types";
|
||||
import { isInvertedRegExp, isValidRegExp } from "../../../lib/src/common/utils";
|
||||
|
||||
export let apply: (args: string[]) => Promise<void> = (_: string[]) => Promise.resolve();
|
||||
export let patterns = [] as CustomRegExpSource[];
|
||||
export let originals = [] as CustomRegExpSource[];
|
||||
|
||||
export let apply: (args: CustomRegExpSource[]) => Promise<void> = (_: CustomRegExpSource[]) => Promise.resolve();
|
||||
function revert() {
|
||||
patterns = [...originals];
|
||||
}
|
||||
const CHECK_OK = "✔";
|
||||
const CHECK_NG = "⚠";
|
||||
const MARK_MODIFIED = "✏ ";
|
||||
function checkRegExp(pattern: string) {
|
||||
if (pattern.trim() == "") return "";
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
return CHECK_OK;
|
||||
} catch (ex) {
|
||||
return CHECK_NG;
|
||||
}
|
||||
function checkRegExp(pattern: CustomRegExpSource) {
|
||||
return isValidRegExp(pattern) ? CHECK_OK : CHECK_NG;
|
||||
}
|
||||
$: statusName = patterns.map((e) => checkRegExp(e));
|
||||
$: modified = patterns.map((e, i) => (e != (originals?.[i] ?? "") ? MARK_MODIFIED : ""));
|
||||
|
||||
$: isInvertedExp = patterns.map((e) => isInvertedRegExp(e));
|
||||
function remove(idx: number) {
|
||||
patterns[idx] = "";
|
||||
patterns[idx] = "" as CustomRegExpSource;
|
||||
}
|
||||
function add() {
|
||||
patterns = [...patterns, ""];
|
||||
patterns = [...patterns, "" as CustomRegExpSource];
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -33,7 +30,9 @@
|
||||
{#each patterns as pattern, idx}
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<li>
|
||||
<label>{modified[idx]}{statusName[idx]}</label><input type="text" bind:value={pattern} class={modified[idx]} />
|
||||
<label>{modified[idx]}{statusName[idx]}</label>
|
||||
<span class="chip">{isInvertedExp[idx] ? "INVERTED" : ""}</span>
|
||||
<input type="text" bind:value={pattern} class={modified[idx]} />
|
||||
<button class="iconbutton" on:click={() => remove(idx)}>🗑</button>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -43,8 +42,16 @@
|
||||
</label>
|
||||
</li>
|
||||
<li class="buttons">
|
||||
<button on:click={() => apply(patterns)} disabled={statusName.some((e) => e === CHECK_NG) || modified.every((e) => e === "")}>Apply </button>
|
||||
<button on:click={() => revert()} disabled={statusName.some((e) => e === CHECK_NG) || modified.every((e) => e === "")}>Revert </button>
|
||||
<button
|
||||
on:click={() => apply(patterns)}
|
||||
disabled={statusName.some((e) => e === CHECK_NG) || modified.every((e) => e === "")}
|
||||
>Apply
|
||||
</button>
|
||||
<button
|
||||
on:click={() => revert()}
|
||||
disabled={statusName.some((e) => e === CHECK_NG) || modified.every((e) => e === "")}
|
||||
>Revert
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -85,4 +92,14 @@
|
||||
button.iconbutton {
|
||||
max-width: 4em;
|
||||
}
|
||||
.chip {
|
||||
background-color: var(--tag-background);
|
||||
color: var(--tag-color);
|
||||
padding: var(--size-2-1) var(--size-4-1);
|
||||
border-radius: 0.5em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.chip:empty {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -346,8 +346,8 @@ export const SettingInformation: Partial<Record<keyof AllSettings, Configuration
|
||||
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)",
|
||||
name: "Compute revisions for chunks",
|
||||
desc: "If this enabled, all chunks will be stored with the revision made from its content.",
|
||||
},
|
||||
sendChunksBulkMaxSize: {
|
||||
name: "Maximum size of chunks to send in one request",
|
||||
@@ -373,6 +373,22 @@ export const SettingInformation: Partial<Record<keyof AllSettings, Configuration
|
||||
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.",
|
||||
},
|
||||
useRequestAPI: {
|
||||
name: "Use Request API to avoid `inevitable` CORS problem",
|
||||
desc: "If enabled, the request API will be used to avoid `inevitable` CORS problems. This is a workaround and may not work in all cases. PLEASE READ THE DOCUMENTATION BEFORE USING THIS OPTION. This is a less-secure option.",
|
||||
},
|
||||
hideFileWarningNotice: {
|
||||
name: "Show status icon instead of file warnings banner",
|
||||
desc: "If enabled, the ⛔ icon will be shown inside the status instead of the file warnings banner. No details will be shown.",
|
||||
},
|
||||
bucketPrefix: {
|
||||
name: "File prefix on the bucket",
|
||||
desc: "Effectively a directory. Should end with `/`. e.g., `vault-name/`.",
|
||||
},
|
||||
};
|
||||
function translateInfo(infoSrc: ConfigurationItem | undefined | false) {
|
||||
if (!infoSrc) return false;
|
||||
|
||||
@@ -1,27 +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>;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export interface Rebuilder {
|
||||
): Promise<void>;
|
||||
$rebuildRemote(): Promise<void>;
|
||||
$rebuildEverything(): Promise<void>;
|
||||
$fetchLocal(makeLocalChunkBeforeSync?: boolean): Promise<void>;
|
||||
$fetchLocal(makeLocalChunkBeforeSync?: boolean, preventMakeLocalFilesBeforeSync?: boolean): Promise<void>;
|
||||
|
||||
scheduleRebuild(): Promise<void>;
|
||||
scheduleFetch(): Promise<void>;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
UXFolderInfo,
|
||||
UXStat,
|
||||
} from "../../lib/src/common/types";
|
||||
import type { CustomRegExp } from "../../lib/src/common/utils";
|
||||
|
||||
export interface StorageAccess {
|
||||
deleteVaultItem(file: FilePathWithPrefix | UXFileInfoStub | UXFolderInfo): Promise<void>;
|
||||
@@ -38,7 +39,7 @@ export interface StorageAccess {
|
||||
getFiles(): UXFileInfoStub[];
|
||||
getFileNames(): FilePathWithPrefix[];
|
||||
|
||||
touched(file: UXFileInfoStub | FilePathWithPrefix): void;
|
||||
touched(file: UXFileInfoStub | FilePathWithPrefix): Promise<void>;
|
||||
recentlyTouched(file: UXFileInfoStub | FilePathWithPrefix): boolean;
|
||||
clearTouched(): void;
|
||||
|
||||
@@ -48,8 +49,8 @@ export interface StorageAccess {
|
||||
|
||||
getFilesIncludeHidden(
|
||||
basePath: string,
|
||||
includeFilter?: RegExp[],
|
||||
excludeFilter?: RegExp[],
|
||||
includeFilter?: CustomRegExp[],
|
||||
excludeFilter?: CustomRegExp[],
|
||||
skipFolder?: string[]
|
||||
): Promise<FilePath[]>;
|
||||
}
|
||||
|
||||
@@ -8,37 +8,29 @@ import {
|
||||
EVENT_SETTING_SAVED,
|
||||
eventHub,
|
||||
} from "../../common/events.ts";
|
||||
import { $f, setLang } from "../../lib/src/common/i18n.ts";
|
||||
import { $msg, setLang } from "../../lib/src/common/i18n.ts";
|
||||
import { versionNumberString2Number } from "../../lib/src/string_and_binary/convert.ts";
|
||||
import { cancelAllPeriodicTask, cancelAllTasks } from "octagonal-wheels/concurrency/task";
|
||||
import { stopAllRunningProcessors } from "octagonal-wheels/concurrency/processor";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ICoreModule } from "../ModuleTypes.ts";
|
||||
import { EVENT_PLATFORM_UNLOADED } from "../../lib/src/PlatformAPIs/base/APIBase.ts";
|
||||
|
||||
export class ModuleLiveSyncMain extends AbstractModule implements ICoreModule {
|
||||
async $$onLiveSyncReady() {
|
||||
if (!(await this.core.$everyOnLayoutReady())) return;
|
||||
eventHub.emitEvent(EVENT_LAYOUT_READY);
|
||||
if (this.settings.suspendFileWatching || this.settings.suspendParseReplicationResult) {
|
||||
const ANSWER_KEEP = "Keep LiveSync disabled";
|
||||
const ANSWER_RESUME = "Resume and restart Obsidian";
|
||||
const message = `Self-hosted LiveSync has been configured to ignore some events. Is this correct?
|
||||
|
||||
| Type | Status | Note |
|
||||
|:---:|:---:|---|
|
||||
| Storage Events | ${this.settings.suspendFileWatching ? "suspended" : "active"} | Every modification will be ignored |
|
||||
| Database Events | ${this.settings.suspendParseReplicationResult ? "suspended" : "active"} | Every synchronised change will be postponed |
|
||||
|
||||
Do you want to resume them and restart Obsidian?
|
||||
|
||||
> [!DETAILS]-
|
||||
> These flags are set by the plug-in while rebuilding, or fetching. If the process ends abnormally, it may be kept unintended.
|
||||
> If you are not sure, you can try to rerun these processes. Make sure to back your vault up.
|
||||
`;
|
||||
const ANSWER_KEEP = $msg("moduleLiveSyncMain.optionKeepLiveSyncDisabled");
|
||||
const ANSWER_RESUME = $msg("moduleLiveSyncMain.optionResumeAndRestart");
|
||||
const message = $msg("moduleLiveSyncMain.msgScramEnabled", {
|
||||
fileWatchingStatus: this.settings.suspendFileWatching ? "suspended" : "active",
|
||||
parseReplicationStatus: this.settings.suspendParseReplicationResult ? "suspended" : "active",
|
||||
});
|
||||
if (
|
||||
(await this.core.confirm.askSelectStringDialogue(message, [ANSWER_KEEP, ANSWER_RESUME], {
|
||||
defaultAction: ANSWER_KEEP,
|
||||
title: "Scram Enabled",
|
||||
title: $msg("moduleLiveSyncMain.titleScramEnabled"),
|
||||
})) == ANSWER_RESUME
|
||||
) {
|
||||
this.settings.suspendFileWatching = false;
|
||||
@@ -56,11 +48,11 @@ Do you want to resume them and restart Obsidian?
|
||||
if (!(await this.core.$everyOnFirstInitialize())) return;
|
||||
await this.core.$$realizeSettingSyncMode();
|
||||
fireAndForget(async () => {
|
||||
this._log(`Additional safety scan..`, LOG_LEVEL_VERBOSE);
|
||||
this._log($msg("moduleLiveSyncMain.logAdditionalSafetyScan"), LOG_LEVEL_VERBOSE);
|
||||
if (!(await this.core.$allScanStat())) {
|
||||
this._log(`Additional safety scan has failed on a module`, LOG_LEVEL_NOTICE);
|
||||
this._log($msg("moduleLiveSyncMain.logSafetyScanFailed"), LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this._log(`Additional safety scan completed`, LOG_LEVEL_VERBOSE);
|
||||
this._log($msg("moduleLiveSyncMain.logSafetyScanCompleted"), LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -80,9 +72,9 @@ Do you want to resume them and restart Obsidian?
|
||||
this.$$wireUpEvents();
|
||||
// debugger;
|
||||
eventHub.emitEvent(EVENT_PLUGIN_LOADED, this.core);
|
||||
this._log("loading plugin");
|
||||
this._log($msg("moduleLiveSyncMain.logLoadingPlugin"));
|
||||
if (!(await this.core.$everyOnloadStart())) {
|
||||
this._log("Plugin initialisation was cancelled by a module", LOG_LEVEL_NOTICE);
|
||||
this._log($msg("moduleLiveSyncMain.logPluginInitCancelled"), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
// this.addUIs();
|
||||
@@ -91,10 +83,10 @@ Do you want to resume them and restart Obsidian?
|
||||
//@ts-ignore
|
||||
const packageVersion: string = PACKAGE_VERSION || "0.0.0";
|
||||
|
||||
this._log($f`Self-hosted LiveSync${" v"}${manifestVersion} ${packageVersion}`);
|
||||
this._log($msg("moduleLiveSyncMain.logPluginVersion", { manifestVersion, packageVersion }));
|
||||
await this.core.$$loadSettings();
|
||||
if (!(await this.core.$everyOnloadAfterLoadSettings())) {
|
||||
this._log("Plugin initialisation was cancelled by a module", LOG_LEVEL_NOTICE);
|
||||
this._log($msg("moduleLiveSyncMain.logPluginInitCancelled"), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const lsKey = "obsidian-live-sync-ver" + this.core.$$getVaultName();
|
||||
@@ -102,7 +94,7 @@ Do you want to resume them and restart Obsidian?
|
||||
|
||||
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
|
||||
if (lastVersion > this.settings.lastReadUpdates && this.settings.isConfigured) {
|
||||
this._log($f`LiveSync has updated, please read the changelog!`, LOG_LEVEL_NOTICE);
|
||||
this._log($msg("moduleLiveSyncMain.logReadChangelog"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
@@ -117,7 +109,7 @@ Do you want to resume them and restart Obsidian?
|
||||
this.settings.syncOnFileOpen = false;
|
||||
this.settings.syncAfterMerge = false;
|
||||
this.settings.periodicReplication = false;
|
||||
this.settings.versionUpFlash = $f`LiveSync has been updated, In case of breaking updates, all automatic synchronization has been temporarily disabled. Ensure that all devices are up to date before enabling.`;
|
||||
this.settings.versionUpFlash = $msg("moduleLiveSyncMain.logVersionUpdate");
|
||||
await this.saveSettings();
|
||||
}
|
||||
localStorage.setItem(lsKey, `${VER}`);
|
||||
@@ -148,7 +140,9 @@ Do you want to resume them and restart Obsidian?
|
||||
}
|
||||
await this.localDatabase.close();
|
||||
}
|
||||
this._log($f`unloading plugin`);
|
||||
eventHub.emitEvent(EVENT_PLATFORM_UNLOADED);
|
||||
eventHub.offAll();
|
||||
this._log($msg("moduleLiveSyncMain.logUnloadingPlugin"));
|
||||
}
|
||||
|
||||
async $$realizeSettingSyncMode(): Promise<void> {
|
||||
|
||||
16
styles.css
16
styles.css
@@ -17,6 +17,7 @@
|
||||
/* min-height: 280px; */
|
||||
max-height: 280px;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.op-pre {
|
||||
@@ -435,4 +436,19 @@ span.ls-mark-cr::after {
|
||||
|
||||
.sls-dialogue-note-countdown {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.sls-qr {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
.sls-keypair pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
|
||||
}
|
||||
@@ -5,6 +5,9 @@ if you want to view the source, please visit the github repository of this plugi
|
||||
`;
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
/***
|
||||
* @type import("terser").MinifyOptions
|
||||
*/
|
||||
const terserOption = {
|
||||
sourceMap: !prod
|
||||
? {
|
||||
@@ -28,7 +31,6 @@ const terserOption = {
|
||||
evaluate: true,
|
||||
dead_code: true,
|
||||
// directives: true,
|
||||
// conditionals: true,
|
||||
inline: 3,
|
||||
join_vars: true,
|
||||
loops: true,
|
||||
@@ -38,12 +40,25 @@ const terserOption = {
|
||||
arrows: true,
|
||||
collapse_vars: true,
|
||||
comparisons: true,
|
||||
//@ts-ignore
|
||||
lhs_constants: true,
|
||||
hoist_props: true,
|
||||
side_effects: true,
|
||||
ecma: 2018,
|
||||
// hoist_vars: true,
|
||||
// hoist_funs: true,
|
||||
if_return: true,
|
||||
// unsafe_math: true,
|
||||
unused: true,
|
||||
// --
|
||||
typeofs: true,
|
||||
properties: true,
|
||||
module: true,
|
||||
booleans: true,
|
||||
conditionals: true,
|
||||
hoist_funs: true,
|
||||
hoist_vars: true,
|
||||
// toplevel: "vars",
|
||||
},
|
||||
mangle: false,
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"target": "ES2018",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["svelte", "node"],
|
||||
// "importsNotUsedAsValues": "error",
|
||||
"importHelpers": false,
|
||||
@@ -16,7 +16,11 @@
|
||||
"noEmit": true,
|
||||
"lib": ["es2018", "DOM", "ES5", "ES6", "ES7", "es2019.array", "ES2021.WeakRef", "ES2020.BigInt", "ESNext.Intl"],
|
||||
"strictBindCallApply": true,
|
||||
"strictFunctionTypes": true
|
||||
"strictFunctionTypes": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@lib/*": ["src/lib/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["pouchdb-browser-webpack", "utils"]
|
||||
|
||||
257
updates.md
257
updates.md
@@ -1,170 +1,163 @@
|
||||
## 0.24.0
|
||||
|
||||
I know that we have been waiting for a long time. It is finally released!
|
||||
|
||||
Over the past three years since the inception of the plugin, various features have been implemented to address diverse user needs. This is truly honourable, and I am grateful for your years of support. However, this process has led to an increasingly disorganised codebase, with features becoming entangled. Consequently, this has led to a situation where bugs can go unnoticed and resolving one issue may inadvertently introduce another.
|
||||
|
||||
In 0.24.0, I reorganised the previously jumbled main codebase into clearly defined modules. Although I had assumed that the total size of the code would not increase, I discovered that it has in fact increased. While the complexity is still considerable, the refactoring has improved the clarity of the code's structure. Additionally, while testing the release candidates, we still found many bugs to fix, which helped to make this plug-in robust and stable. Therefore, we are now ready to use the updated plug-in, and in addition to that, proceed to the next step.
|
||||
|
||||
This is also the first step towards a fully-fledged-fancy LiveSync, not just a plug-in from Obsidian. Of course, it will still be a plug-in primarily and foremost, but this development marks a significant step towards the self-hosting concept.
|
||||
|
||||
Finally, I would like to once again express my respect and gratitude to all of you. My gratitude extends to all of the dev testers! Your contributions have certainly made the plug-in robust and stable!
|
||||
|
||||
Thank you, and I hope your troubles will be resolved!
|
||||
|
||||
---
|
||||
|
||||
## 0.24.7
|
||||
|
||||
### Fixed (Security)
|
||||
|
||||
- Assigning IDs to chunks has been corrected for more safety.
|
||||
- Before version 0.24.6, there were possibilities in End-to-End encryption where a brute-force attack could be carried out against an E2EE passphrase via a chunk ID if a zero-byte file was present. Now the chunk ID should be assigned more safely, and not all of passphrases are used for generating the chunk ID.
|
||||
- This is a security fix, and it is recommended to update and rebuild database to this version as soon as possible.
|
||||
- Note: It keeps the compatibility with the previous versions, but the chunk ID will be changed for the new files and modified files. Hence, deduplication will not work for the files which are modified after the update. It is recommended to rebuild the database to avoid the potential issues, and reduce the database size.
|
||||
- Note2: This fix is only for with E2EE. Plain synchronisation is not affected by this issue.
|
||||
## 0.24.28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Now the conflict resolving dialogue is automatically closed after the conflict has been resolved (and transferred from other devices; or written by some other resolution).
|
||||
- Resolving conflicts by timestamp is now working correctly.
|
||||
- It also fixes customisation sync.
|
||||
- Batch Update is no longer available in LiveSync mode to avoid unexpected behaviour. (#653)
|
||||
- Now compatible with Cloudflare R2 again for bucket synchronisation.
|
||||
- @edo-bari-ikutsu, thank you for [your contribution](https://github.com/vrtmrz/livesync-commonlib/pull/12)!
|
||||
- Prevention of broken behaviour due to database connection failures added (#649).
|
||||
|
||||
## 0.24.27
|
||||
|
||||
### Improved
|
||||
|
||||
- Notifications can be suppressed for the hidden files update now.
|
||||
- No longer uses the old-xxhash and sha1 for generating the chunk ID. Chunk ID is now generated with the new algorithm (Pure JavaScript hash implementation; which is using Murmur3Hash and FNV-1a now used).
|
||||
|
||||
## 0.24.6
|
||||
|
||||
### Fixed (Quick Fix)
|
||||
|
||||
- Fixed the issue of log is not displayed on the log pane if the pane has not been shown on startup.
|
||||
- This release is only for it. However, fixing this had been necessary to report any other issues.
|
||||
|
||||
## 0.24.5
|
||||
- We can use prefix for path for the Bucket synchronisation.
|
||||
- For example, if you set the `vaultName/` as a prefix for the bucket in the root directory, all data will be transferred to the bucket under the `vaultName/` directory.
|
||||
- The "Use Request API to avoid `inevitable` CORS problem" option is now promoted to the normal setting, not a niche patch.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed incorrect behaviour when comparing objects with undefined as a property value.
|
||||
- Now switching replicators applied immediately, without the need to restart Obsidian.
|
||||
|
||||
### Tidied up
|
||||
|
||||
- Some dependencies have been updated to the latest version.
|
||||
|
||||
## 0.24.26
|
||||
|
||||
This update introduces an option to circumvent Cross-Origin Resource Sharing
|
||||
(CORS) constraints for CouchDB requests, by leveraging Obsidian's native request
|
||||
API. The implementation of such a feature had previously been deferred due to
|
||||
significant security considerations.
|
||||
|
||||
CORS is a vital security mechanism, enabling servers like CouchDB -- which
|
||||
functions as a sophisticated REST API -- to control access from different
|
||||
origins, thereby ensuring secure communication across trust boundaries. I had
|
||||
long hesitated to offer a CORS circumvention method, as it deviates from
|
||||
security best practices; My preference was for users to configure CORS correctly
|
||||
on the server-side.
|
||||
|
||||
However, this policy has shifted due to specific reports of intractable
|
||||
CORS-related configuration issues, particularly within enterprise proxy
|
||||
environments where proxy servers can unpredictably alter or block
|
||||
communications. Given that a primary objective of the "Self-hosted LiveSync"
|
||||
plugin is to facilitate secure Obsidian usage within stringent corporate
|
||||
settings, addressing these 'unavoidable' user-reported problems became
|
||||
essential. Mostly raison d'être of this plugin.
|
||||
|
||||
Consequently, the option "Use Request API to avoid `inevitable` CORS problem"
|
||||
has been implemented. Users are strongly advised to enable this _only_ when
|
||||
operating within a trusted environment. We can enable this option in the `Patch` pane.
|
||||
|
||||
However, just to whisper, this is tremendously fast.
|
||||
|
||||
### New Features
|
||||
|
||||
- Automatic display-language changing according to the Obsidian language
|
||||
setting.
|
||||
- We will be asked on the migration or first startup.
|
||||
- **Note: Please revert to the default language if you report any issues.**
|
||||
- Not all messages are translated yet. We welcome your contribution!
|
||||
- Now we can limit files to be synchronised even in the hidden files.
|
||||
- "Use Request API to avoid `inevitable` CORS problem" has been implemented.
|
||||
- Less secure, please use it only if you are sure that you are in the trusted
|
||||
environment and be able to ignore the CORS. No `Web viewer` or similar tools
|
||||
are recommended. (To avoid the origin forged attack). If you are able to
|
||||
configure the server setting, always that is recommended.
|
||||
- `Show status icon instead of file warnings banner` has been implemented.
|
||||
- If enabled, the ⛔ icon will be shown inside the status instead of the file
|
||||
warnings banner. No details will be shown.
|
||||
|
||||
### Improved
|
||||
|
||||
- The status line and the log summary are now displayed more smoothly and efficiently.
|
||||
- This improvement has also been applied to the logs displayed in the log pane.
|
||||
|
||||
## 0.24.4
|
||||
- All regular expressions can be inverted by prefixing `!!` now.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed so many inefficient and buggy modules inherited from the past.
|
||||
- No longer unexpected files will be gathered during hidden file sync.
|
||||
- No longer broken `\n` and new-line characters during the bucket
|
||||
synchronisation.
|
||||
- We can purge the remote bucket again if we using MinIO instead of AWS S3 or
|
||||
Cloudflare R2.
|
||||
- Purging the remote bucket is now more reliable.
|
||||
- 100 files are purged at a time.
|
||||
- Some wrong messages have been fixed.
|
||||
|
||||
### Behaviour changed
|
||||
|
||||
- Entering into the deeper directories to gather the hidden files is now limited
|
||||
by `/` or `\/` prefixed ignore filters. (It means that directories are scanned
|
||||
deeper than before).
|
||||
- However, inside the these directories, the files are still limited by the
|
||||
ignore filters.
|
||||
|
||||
### Etcetera
|
||||
|
||||
- Some code has been tidied up.
|
||||
- Trying less warning-suppressing and be more safer-coding.
|
||||
- Dependent libraries have been updated to the latest version.
|
||||
- Some build processes have been separated to `pre` and `post` processes.
|
||||
|
||||
## 0.24.25
|
||||
|
||||
### Improved
|
||||
|
||||
- Tasks are now executed in an efficient asynchronous library.
|
||||
- On-demand chunk fetching is now more efficient and keeps the interval between requests.
|
||||
- This will reduce the load on the server and the network.
|
||||
- And, safe for the Cloudant.
|
||||
|
||||
## 0.24.3
|
||||
|
||||
### Improved
|
||||
|
||||
- Many messages have been improved for better understanding as thanks to the fine works of @Volkor3-16! Thank you so much!
|
||||
- Documentations also have been updated to reflect the changes in the messages.
|
||||
- Now the style of In-Editor Status has been solid for some Android devices.
|
||||
|
||||
## 0.24.2
|
||||
|
||||
### Rewritten
|
||||
|
||||
- Hidden File Sync is now respects the file changes on the storage. Not simply comparing modified times.
|
||||
- This makes hidden file sync more robust and reliable.
|
||||
- Peer-to-peer synchronisation has been got more robust.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `Scan hidden files before replication` is now configurable again.
|
||||
- Some unexpected errors are now handled more gracefully.
|
||||
- Meaningless event passing during boot sequence is now prevented.
|
||||
- Error handling for non-existing files has been fixed.
|
||||
- Hidden files will not be batched to avoid the potential error.
|
||||
- This behaviour had been causing the error in the previous versions in specific situations.
|
||||
- The log which checking automatic conflict resolution is now in verbose level.
|
||||
- Replication log (skipping non-targetting files) shows the correct information.
|
||||
- The dialogue that asking enabling optional feature during `Rebuild Everything` now prevents to show the `overwrite` option.
|
||||
- The rebuilding device is the first, meaningless.
|
||||
- Files with different modified time but identical content are no longer processed repeatedly.
|
||||
- Some unexpected errors which caused after terminating plug-in are now avoided.
|
||||
-
|
||||
- No longer broken falsy values in settings during set-up by the QR code
|
||||
generation.
|
||||
|
||||
### Improved
|
||||
### Refactored
|
||||
|
||||
- JSON files are now more transferred efficiently.
|
||||
- Now the JSON files are transferred in more fine chunks, which makes the transfer more efficient.
|
||||
- Some `window` references now have pointed to `globalThis`.
|
||||
- Some sloppy-import has been fixed.
|
||||
- A server side implementation `Synchromesh` has been suffixed with `deno`
|
||||
instead of `server` now.
|
||||
|
||||
## 0.24.1
|
||||
## 0.24.24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Vault History can show the correct information of match-or-not for each file and database even if it is a binary file.
|
||||
- `Sync settings via markdown` is now hidden during the setup wizard.
|
||||
- Verify and Fix will ignore the hidden files if the hidden file sync is disabled.
|
||||
|
||||
#### New feature
|
||||
|
||||
- Now we can fetch the tweaks from the remote database while the setting dialogue and wizard are processing.
|
||||
- No longer broken JSON files including `\n`, during the bucket synchronisation.
|
||||
(#623)
|
||||
- Custom headers and JWT tokens are now correctly sent to the server during
|
||||
configuration checking. (#624)
|
||||
|
||||
### Improved
|
||||
|
||||
- More things are moved to the modules.
|
||||
- Includes the Main codebase. Now `main.ts` is almost stub.
|
||||
- EventHub is now more robust and typesafe.
|
||||
- Bucket synchronisation has been enhanced for better performance and
|
||||
reliability.
|
||||
- Now less duplicated chunks are sent to the server. Note: If you have
|
||||
encountered about too less chunks, please let me know. However, you can send
|
||||
it to the server by `Overwrite remote`.
|
||||
- Fetching conflicted files from the server is now more reliable.
|
||||
- Dependent libraries have been updated to the latest version.
|
||||
- Also, let me know if you have encountered any issues with this update.
|
||||
Especially you are using a device that has been in use for a little
|
||||
longer.
|
||||
|
||||
## 0.24.0
|
||||
## 0.24.23
|
||||
|
||||
### New Feature
|
||||
|
||||
- Now, we can send custom headers to the server.
|
||||
- They can be sent to either CouchDB or Object Storage.
|
||||
- Authentication with JWT in CouchDB is now supported.
|
||||
- I will describe steps later, but please refer to the
|
||||
[CouchDB document](https://docs.couchdb.org/en/stable/config/auth.html#authentication-configuration).
|
||||
- A JWT keypair for testing can be generated in the setting dialogue.
|
||||
|
||||
### Improved
|
||||
|
||||
- The welcome message is now more simple to encourage the use of the Setup-URI.
|
||||
- The secondary message is also simpler to guide users to Minimal Setup.
|
||||
- But Setup-URI will be recommended again, due to its importance.
|
||||
- These dialogues contain a link to the documentation which can be clicked.
|
||||
- The minimal setup is more minimal now. And, the setup is more user-friendly.
|
||||
- Now the Configuration of the remote database is checked more robustly, but we can ignore the warning and proceed with the setup.
|
||||
- Before we are asked about each feature, we are asked if we want to use optional features in the first place.
|
||||
- This is to prevent the user from being overwhelmed by the features.
|
||||
- And made it clear that it is not recommended for new users.
|
||||
- Many messages have been improved for better understanding.
|
||||
- Ridiculous messages have been (carefully) refined.
|
||||
- Dialogues are more informative and friendly.
|
||||
- A lot of messages have been mostly rewritten, leveraging Markdown.
|
||||
- Especially auto-closing dialogues are now explicitly labelled: `To stop the countdown, tap anywhere on the dialogue`.
|
||||
- Now if the is plugin configured to ignore some events, we will get a chance to fix it, in addition to the warning.
|
||||
- And why that has happened is also explained in the dialogue.
|
||||
- A note relating to device names has been added to Customisation Sync on the setting dialogue.
|
||||
- We can verify and resolve also the hidden files now.
|
||||
- The QR Code for set-up can be shown also from the setting dialogue now.
|
||||
- Conflict checking for preventing unexpected overwriting on the boot-up process
|
||||
has been quite faster.
|
||||
|
||||
### Fixed
|
||||
|
||||
- We can resolve the conflict of the JSON file correctly now.
|
||||
- Verifying files between the local database and storage is now working correctly.
|
||||
- While restarting the plug-in, the shown dialogues will be automatically closed to avoid unexpected behaviour.
|
||||
- Replicated documents that the local device has configured to ignore are now correctly ignored.
|
||||
- The chunks of the document on the local device during the first transfer will be created correctly.
|
||||
- And why we should create them is now explained in the dialogue.
|
||||
- If optional features have been enabled in the wizard, `Enable advanced features` will be toggled correctly.
|
||||
The hidden file sync is now working correctly. - Now the deletion of hidden files is correctly synchronised.
|
||||
- Customisation Sync is now working correctly together with hidden file sync.
|
||||
- No longer database suffix is stored in the setting sharing markdown.
|
||||
- A fair number of bugs have been fixed.
|
||||
- Some bugs on Dev and Testing modules have been fixed.
|
||||
|
||||
### Changed
|
||||
|
||||
- Some default settings have been changed for an easier new user experience.
|
||||
- Preventing the meaningless migration of the settings.
|
||||
|
||||
### Tiding
|
||||
|
||||
- The codebase has been reorganised into clearly defined modules.
|
||||
- Commented-out codes have been gradually removed.
|
||||
|
||||
Older notes are in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
|
||||
Older notes are in
|
||||
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
|
||||
|
||||
2079
updates_old.md
2079
updates_old.md
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user