mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-02-22 20:18:48 +00:00
Compare commits
78 Commits
0.22.11
...
snyk-fix-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0548a280a | ||
|
|
86d5582f37 | ||
|
|
697ee1855b | ||
|
|
b8edc85528 | ||
|
|
e2740cbefe | ||
|
|
a96e4e4472 | ||
|
|
dd26bbfe64 | ||
|
|
6b9bd473cf | ||
|
|
4be4fa6cc7 | ||
|
|
a9745e850e | ||
|
|
7b9515a47e | ||
|
|
220dce51f2 | ||
|
|
a23fc866c0 | ||
|
|
5c86966d89 | ||
|
|
29ed4d2b95 | ||
|
|
16c6c52128 | ||
|
|
8b94a0b72e | ||
|
|
c5ac76d916 | ||
|
|
b67a6db8a1 | ||
|
|
d4202161e8 | ||
|
|
2a2b39009c | ||
|
|
bf3a6e7570 | ||
|
|
069b8513d1 | ||
|
|
128b1843df | ||
|
|
fd722b1fe5 | ||
|
|
0bf087dba0 | ||
|
|
3a4b59b998 | ||
|
|
8fc9d51c45 | ||
|
|
35feb5bf93 | ||
|
|
b3a85c5462 | ||
|
|
7b0ac22c3b | ||
|
|
dca8e4b2a4 | ||
|
|
89de2dcc37 | ||
|
|
172b08dbb3 | ||
|
|
d518a3fc1b | ||
|
|
c6ed867498 | ||
|
|
4f4923e977 | ||
|
|
a5ebf29b3d | ||
|
|
ee465184c8 | ||
|
|
d7d4f1e6f2 | ||
|
|
cbf5023593 | ||
|
|
3925052f92 | ||
|
|
1934418258 | ||
|
|
2ae018b2bd | ||
|
|
8474497985 | ||
|
|
b5714cc83b | ||
|
|
133f5a7109 | ||
|
|
daa3feebf1 | ||
|
|
7b5f7d0fbf | ||
|
|
29532193cb | ||
|
|
5b4309c09d | ||
|
|
16ef582453 | ||
|
|
3e22f70c7a | ||
|
|
0a8dbe097e | ||
|
|
2c0fcf74d0 | ||
|
|
a1ab1efd5d | ||
|
|
c8fcf2d0d5 | ||
|
|
c384e2f7fb | ||
|
|
99c1c7dc1a | ||
|
|
84adec4b1a | ||
|
|
f0b202bd91 | ||
|
|
d54b7e2d93 | ||
|
|
6952ef37f5 | ||
|
|
9630bcbae8 | ||
|
|
c3f925ab9a | ||
|
|
034dc0538f | ||
|
|
b6136df836 | ||
|
|
24aacdc2a1 | ||
|
|
f91109b1ad | ||
|
|
e76e7ae8ea | ||
|
|
f7fbe85d65 | ||
|
|
0313443b29 | ||
|
|
755c30f468 | ||
|
|
b00b0cc5e5 | ||
|
|
d7985a6b41 | ||
|
|
486e816902 | ||
|
|
ef9b19c24b | ||
|
|
4ed9494176 |
@@ -2,7 +2,7 @@
|
||||
# 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 as the server.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -45,7 +45,7 @@ This plug-in might be useful for researchers, engineers, and developers with a n
|
||||
2. Configure plug-in in [Quick Setup](docs/quick_setup.md)
|
||||
|
||||
> [!TIP]
|
||||
> We are still able to use IBM Cloudant. However, it is not recommended for several reasons nowadays. Here is [Setup IBM Cloudant](docs/setup_cloudant.md)
|
||||
> 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!
|
||||
|
||||
|
||||
## Information in StatusBar
|
||||
@@ -68,6 +68,7 @@ Synchronization status is shown in the status bar with the following icons.
|
||||
- 💾 Working write storage processes
|
||||
- ⏳ Working read storage processes
|
||||
- 🛫 Pending read storage processes
|
||||
- 📬 Batched read storage processes
|
||||
- ⚙️ Working or pending storage processes of hidden files
|
||||
- 🧩 Waiting chunks
|
||||
- 🔌 Working Customisation items (Configuration, snippets, and plug-ins)
|
||||
|
||||
34
docs/adding_translations.md
Normal file
34
docs/adding_translations.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# How to add translations
|
||||
|
||||
## Getting ready
|
||||
|
||||
1. Clone this repository recursively.
|
||||
```sh
|
||||
git clone --recursive https://github.com/vrtmrz/obsidian-livesync
|
||||
```
|
||||
2. Make `ls-debug` folder under your vault's `.obsidian` folder (as like `.../dev/.obsidian/ls-debug`).
|
||||
|
||||
## Add translations for already defined terms
|
||||
|
||||
1. Install dependencies, and build the plug-in as dev. build.
|
||||
```sh
|
||||
cd obsidian-livesync
|
||||
npm i -D
|
||||
npm run buildDev
|
||||
```
|
||||
|
||||
2. Copy the `main.js` to `.obsidian/plugins/obsidian-livesync` folder of your vault, and run Obsidian-Self-hosted LiveSync.
|
||||
3. You will get the `missing-translation-yyyy-mm-dd.jsonl`, please fill in new translations.
|
||||
4. Build the plug-in again, and confirm that displayed things were expected.
|
||||
5. Merge them into `rosetta.ts`, and make the PR to `https://github.com/vrtmrz/livesync-commonlib`.
|
||||
|
||||
## 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.
|
||||
```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);
|
||||
```
|
||||
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.
|
||||
50
docs/design_docs_of_journalsync.md
Normal file
50
docs/design_docs_of_journalsync.md
Normal file
@@ -0,0 +1,50 @@
|
||||
## The design document of the journal sync
|
||||
|
||||
Original title: Synchronise without CouchDB
|
||||
|
||||
### Goal
|
||||
- Synchronise vaults without CouchDB
|
||||
|
||||
### Motivation
|
||||
- Serving CouchDB is not pretty easy.
|
||||
- Full spec DBaaS (Paid IBM Cloudant) is a bit expensive and lacking of alternatives.
|
||||
- Securing alternatives, from just one protocol.
|
||||
|
||||
### Prerequisite
|
||||
- We should have multiple implementations of the server software.
|
||||
- We should also be able to use SaaS, with a choice of options.
|
||||
- We should require them a reasonable sense of cost, ideally free of charge for trials.
|
||||
- We should be able to serve some instance of the server software, as OSS — with transparency, availability of auditing, and the fact that they actually took place.
|
||||
|
||||
### Methods and implementations
|
||||
|
||||
Ordinarily, local pouchDB and the remote CouchDB are synchronised by sending each missing document through several conversations in their replication protocol. However, to achieve this plan, we cannot rely on CouchDB and its protocols. This limitation is so harsh. However, Overcoming this means gaining new possibilities. After some trials, It was concluded that synchronisation could be completed even if the actions that could be performed were limited to uploading, downloading and retrieving the list. This means we can use any old-fashioned WebDAV server, and Sophisticated “Object storages” such as Self-hosted MinIO, S3, and R2 or any we like. This is realised by sharing and complementing the differences of the journal by each client. Therefore, The focus is therefore on how to identify which are the differences and send them without dynamic communication.
|
||||
|
||||
All clients manage their data in PouchDB. I know this is probably known information, but it has its own journal.
|
||||
|
||||
First, all clients should record to what point in the journal they sent themselves last time. The client then packs from the previous point to the latest when sending and also updates their record. This pack is uploaded to the server with the name starting with the timestamp of its creation. This is the send operation.
|
||||
|
||||
Conversely, when receiving, the packs uploaded to the server that have not yet been received are received in order. This is easy as their names are in date order. When the process is successfully completed, the names of the files received are recorded. The journals from this pack are then reflected in their own database. Conflict resolution is left to PouchDB, so the client only needs to do the work of applying any differences. And here is the key: the client records the ID and revision of the document that was in the journal and applied.
|
||||
|
||||
This key works when creating a pack. When creating a pack, the client omits this 'document recorded as received and used'. This is because received and applied means that it has already been sent by another client and exists on the server. This ensures that unnecessary transmissions do not take place.
|
||||
|
||||
Synchronisation is then always started by receiving. This is a little trick to avoid including unnecessary documents in the pack.
|
||||
|
||||
These behaviours allow clients to voluntarily send and receive only the missing parts of the journal that are not stored on the server, without having to communicate with each other, and still keep a single, consistent journal on the server.
|
||||
|
||||
Source codes actually implemented this is already committed into the repository.
|
||||
|
||||
### Test strategy
|
||||
|
||||
This implementation replaces the synchronisation performed by CouchDB. Therefore, testing was simply done by comparing the same changes to the same vault, replicated in CouchDB, with those done by this implementation.
|
||||
|
||||
### Documentation strategy
|
||||
|
||||
- Documentation should be done in a quick setup, at least.
|
||||
- As several server implementations can be selected, the description is omitted with regard to specific configuration values.
|
||||
- A MinIO set-up might be nice to have. However, it is not considered essential.
|
||||
- It would be a good opportunity to also publish these design documents.
|
||||
|
||||
### Consideration and Conclusion
|
||||
|
||||
This design offers a novel approach to journal synchronisation without relying on CouchDB. It leverages PouchDB's journaling capabilities and leverages simple server-side storage for efficient data exchange. Hence, the new design could be said to have gotten a broader outlook.
|
||||
81
docs/design_docs_of_keep_newborn_chunks.md
Normal file
81
docs/design_docs_of_keep_newborn_chunks.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce the number of chunks which in volatile, and reduce the usage of storage of the remote database in middle or long term.
|
||||
|
||||
## Motivation
|
||||
|
||||
- In the current implementation, Self-hosted LiveSync splits documents into metadata and multiple chunks. In particular, chunks are split so that they do not exceed a certain length.
|
||||
- This is to optimise the transfer and take advantage of the properties of CouchDB. This also complies with the restriction of IBM Cloudant on the size of a single document.
|
||||
- However, creating chunks halfway through each editing operation increases the number of unnecessary chunks.
|
||||
- Chunks are shared by several documents. For this reason, it is not clear whether these chunks are needed or not unless all revisions of all documents are checked. This makes it difficult to remove unnecessary data.
|
||||
- On the other hand, chunks are done in units that can be neatly divided as markdown to ensure relatively accurate de-duplication, even if they are created simultaneously on multiple terminals. Therefore, it is unlikely that the data in the editing process will be reused.
|
||||
- For this reason, we have made features such as Batch save available, but they are not a fundamental solution.
|
||||
- As a result, there is a large amount of data that cannot be erased and is probably unused. Therefore, `Fetch chunks on demand` is currently performed for optimal communication.
|
||||
- If the generation of unnecessary chunks is sufficiently reduced, this function will become unnecessary.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
Details are given below.
|
||||
|
||||
1. The document will henceforth have the property eden.
|
||||
```typescript
|
||||
// Paritally Type
|
||||
type EntryWithEden = {
|
||||
eden: {
|
||||
[key: DocumentID]: {
|
||||
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.
|
||||
- useEden : boolean
|
||||
- Max chunks in eden : number
|
||||
- Max Total chunk lengths in eden: number
|
||||
- Max age while in eden: number
|
||||
3. In the document saving operation, chunks are added to Eden within each document, having the revision number of the existing document. And if some chunks in eden are not used in the operating revision, they would be removed.
|
||||
Then after being so chosen, a few chunks are also chosen to be graduated as an independent `chunk` in following rules, and they would be left the eden:
|
||||
- Those that have already been confirmed to exist as independent chunks.
|
||||
- This confirmation of existence may ideally be determined by a fast first-order determination, e.g. by a Bloom filter.
|
||||
- Those whose length exceeds the configured maximum length.
|
||||
- Those have aged over the configured value, since epoch at the operating revision.
|
||||
- Those whose total length, when added up when they are arranged in reverse order of the revision in which they were generated, is after the point at which they exceed the max length in the configuration. Or, those after the configured maximum items.
|
||||
4. In the document loading operation, chunks are firstly read from these eden.
|
||||
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
|
||||
|
||||
1. Confirm that synchronisation with the previous version is possible with this feature disabled.
|
||||
2. With this feature enabled, connect from the previous version and confirm that errors are detected in the previous version but the files are not corrupted.
|
||||
3. Ensure that the two versions with this feature enabled can withstand normal use.
|
||||
|
||||
## Documentation strategy
|
||||
|
||||
- This document is published, and will be referred from the release note.
|
||||
- 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.
|
||||
|
||||
### Consideration and Conclusion
|
||||
To be described after implemented, tested, and, released.
|
||||
55
docs/design_docs_of_sharing_tweak_value.md
Normal file
55
docs/design_docs_of_sharing_tweak_value.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Sharing `Tweak values`
|
||||
|
||||
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.
|
||||
|
||||
## Goal
|
||||
|
||||
Share `Tweak values` between clients to match the chunk lengths, and match per-server configurations for better performance.
|
||||
|
||||
## Motivation
|
||||
|
||||
- In the current implementation, Self-hosted LiveSync splits documents into metadata and multiple chunks. In particular, chunks are split so that they do not exceed a certain length.
|
||||
- This is to optimise the transfer and take advantage of the properties of CouchDB. This also complies with the restriction of IBM Cloudant on the size of a single document.
|
||||
- The length of this chunk is adjusted according to a configured factor. Therefore, if this is inconsistent between clients, de-duplication will not work. This is because, in fact, they point to the same content in total, but are split in different places. This results in unnecessary transfers or storage consumption.
|
||||
- The same applies to hash algorithms.
|
||||
- There are more configurations which `preferred to be matched`, even if it is not required. such as the maximum size of files to be handled and the interval between requests to the remote database, unless there are specific circumstances.
|
||||
- To avoid the tragedy of "Too many toggles", "Unexpected transfer amount", or "Poor performance" at once, the plug-in should know these problems or potential problems and be able to let us know.
|
||||
|
||||
## Prerequisite
|
||||
- We must be informed of a discrepancy in a configured value that is required to be absolutely consistent and be able to make a decision on the spot.
|
||||
- We should be able to see on the configuration dialogue, that there is a discrepancy between configured values that should be matched, and it should be possible to adjust them to a specific one of them (or default).
|
||||
- We must not be exposed to unexpected; such as leaking credentials or their secrets.
|
||||
|
||||
## Outlined methods and implementation plans
|
||||
### Abstract
|
||||
- In the current implementation, each client checks the remote database for the existence of their node information, to detect whether the remote database accepts them.
|
||||
- This is what 'Lock' is all about.
|
||||
- To achieve this feature, the client will also send each configuration value. However, the configuration contains credentials and/or secret values. Hence we cannot send all of them.
|
||||
- With a favourable prediction, Self-hosted LiveSync will continue to increase in feature. Each time this happens, the number of configuration values to be kept secret will also increase. Therefore, they must be handled by an allow-list.
|
||||
- This allow-listed configuration are the `Tweak values`.
|
||||
- If the plug-in detects mismatched `Tweak values` on checking the remote database, the plug-in will ask us to decide which is win (Mine, or theirs).
|
||||
- Node information is one of the documents. Therefore, it will be replicated and saved locally. While showing dialogue, show the notice on each `Match preferred` configuration.
|
||||
|
||||
## Note
|
||||
This feature should be mostly harmless. We will not be able to disable this.
|
||||
|
||||
## Test strategy
|
||||
|
||||
A: During synchronisation.
|
||||
1. No message shall be displayed with all settings matched.
|
||||
2. Message shall be displayed when there are mismatched, required match items.
|
||||
1. The setting values can be changed according to the message.
|
||||
2. The message can be ignored.
|
||||
3. The message shall not be displayed even if there are mismatched items which is recommended to be matched.
|
||||
|
||||
B: On the setting dialogue.
|
||||
1. All mismatched items shall be highlighted in some way.
|
||||
|
||||
## Documentation strategy
|
||||
|
||||
- This document is published, and will be referred from the release note.
|
||||
- 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.
|
||||
|
||||
### Consideration and Conclusion
|
||||
To be described after implemented, tested, and, released.
|
||||
@@ -16,7 +16,8 @@ There are three methods to set up Self-hosted LiveSync.
|
||||
|
||||
### 1. Using setup URIs
|
||||
|
||||
> [!TIP] What is the setup URI? Why is it required?
|
||||
> [!TIP]
|
||||
> What is the setup URI? Why is it required?
|
||||
> The setup URI is the encrypted representation of Self-hosted LiveSync configuration as a URI. This starts `obsidian://setuplivesync?settings=`. This is encrypted with a passphrase, so that it can be shared relatively securely between devices. It is a bit long, but it is one line. This allows a series of settings to be set at once without any inconsistencies.
|
||||
>
|
||||
> If you have configured the remote database by [Automated setup on Fly.io](./setup_flyio.md#a-very-automated-setup) or [set up your server with the tool](./setup_own_server.md#1-generate-the-setup-uri-on-a-desktop-device-or-server), **you should have one of them**
|
||||
@@ -44,19 +45,38 @@ If you do not have any setup URI, Press the `start` button. The setting dialogue
|
||||
|
||||

|
||||
|
||||
#### Remote database configuration
|
||||
|
||||
1. Enter the information for the database we have set up.
|
||||
#### Select the remote type
|
||||
|
||||
1. Select the Remote Type from dropdown list.
|
||||
We now have a choice between CouchDB (and its compatibles) and object storage (MinIO, S3, R2). CouchDB is the first choice and is also recommended. And supporting Object Storage is an experimental feature.
|
||||
|
||||
#### Remote configuration
|
||||
|
||||
##### CouchDB
|
||||
|
||||
Enter the information for the database we have set up.
|
||||
|
||||

|
||||
|
||||
##### Object Storage
|
||||
|
||||
#### Test database connection and Check database configuration
|
||||
1. Enter the information for the S3 API and bucket.
|
||||
|
||||

|
||||
|
||||
Note 1: if you use S3, you can leave the Endpoint URL empty.
|
||||
Note 2: if your Object Storage cannot configure the CORS setting fully, you may able to connect to the server by enabling the `Use Custom HTTP Handler` toggle.
|
||||
|
||||
2. Press `Test` of `Test Connection` once and ensure you can connect to the Object Storage.
|
||||
|
||||
#### Only CouchDB: Test database connection and Check database configuration
|
||||
|
||||
We can check the connectivity to the database, and the database settings.
|
||||
|
||||

|
||||
|
||||
#### Check and Fix database configuration
|
||||
#### Only CouchDB: Check and Fix database configuration
|
||||
|
||||
Check the database settings and fix any problems on the spot.
|
||||
|
||||
@@ -81,6 +101,8 @@ We should proceed to the Next step.
|
||||
#### Sync Settings
|
||||
Finally, finish the wizard by selecting a preset for synchronisation.
|
||||
|
||||
Note: If you are going to use Object Storage, you cannot select `LiveSync`.
|
||||
|
||||

|
||||
|
||||
Select any synchronisation methods we want to use and `Apply`. If database initialisation is required, it will be performed at this time. When `All done!` is displayed, we are ready to synchronise.
|
||||
@@ -104,4 +126,4 @@ And, please copy the setup URI by `Copy current settings as a new setup URI` and
|
||||
## At the subsequent device
|
||||
After installing Self-hosted LiveSync on the first device, we should have a setup URI. **The first choice is to use it**. Please share it with the device you want to setup.
|
||||
|
||||
It is completely same as [Using setup URIs on the first device](#1-using-setup-uris). Please refer it.
|
||||
It is completely same as [Using setup URIs on the first device](#1-using-setup-uris). Please refer it.
|
||||
|
||||
@@ -108,13 +108,18 @@ $ export database=obsidiannotes #Please change as you like
|
||||
$ export passphrase=dfsapkdjaskdjasdas #Please change as you like
|
||||
$ deno run -A https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/flyio/generate_setupuri.ts
|
||||
obsidian://setuplivesync?settings=%5B%22tm2DpsOE74nJAryprZO2M93wF%2Fvg.......4b26ed33230729%22%5D
|
||||
|
||||
Your passphrase of Setup-URI is: patient-haze
|
||||
This passphrase is never shown again, so please note it in a safe place.
|
||||
```
|
||||
|
||||
Please keep your passphrase of Setup-URI.
|
||||
|
||||
### 2. Setup Self-hosted LiveSync to Obsidian
|
||||
[This video](https://youtu.be/7sa_I1832Xc?t=146) may help us.
|
||||
1. Install Self-hosted LiveSync
|
||||
2. Choose `Use the copied setup URI` from the command palette and paste the setup URI. (obsidian://setuplivesync?settings=.....).
|
||||
3. Type `welcome` for setup-uri passphrase.
|
||||
3. Type the previously displayed passphrase (`patient-haze`) for setup-uri passphrase.
|
||||
4. Answer `yes` and `Set it up...`, and finish the first dialogue with `Keep them disabled`.
|
||||
5. `Reload app without save` once.
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ services:
|
||||
最后, 创建并启动容器:
|
||||
```
|
||||
# -d will launch detached so the container runs in background
|
||||
docker compose up -d
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 创建数据库
|
||||
|
||||
10
docs/terms.md
Normal file
10
docs/terms.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Terms used in this project
|
||||
|
||||
## Terms
|
||||
|
||||
### Chunks
|
||||
<!-- TBW, sorry for the draft! -->
|
||||
|
||||
|
||||
<!-- Please feel free to write any terms that should be mentioned. And please make pull request. I would love to fill the rest. -->
|
||||
<!-- ### Chunks -->
|
||||
@@ -14,10 +14,13 @@
|
||||
- [Why are the logs volatile and ephemeral?](#why-are-the-logs-volatile-and-ephemeral)
|
||||
- [Some network logs are not written into the file.](#some-network-logs-are-not-written-into-the-file)
|
||||
- [If a file were deleted or trimmed, the capacity of the database should be reduced, right?](#if-a-file-were-deleted-or-trimmed-the-capacity-of-the-database-should-be-reduced-right)
|
||||
- [How can I use the DevTools?](#how-can-i-use-the-devtools)
|
||||
- [Checking the network log](#checking-the-network-log)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [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)
|
||||
|
||||
<!-- - -->
|
||||
@@ -77,7 +80,7 @@ However, the logs would not be kept so long and cleared when restarted. If you w
|
||||
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.
|
||||
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.
|
||||
@@ -87,7 +90,15 @@ And one more thing, we can handle the conflicts on any device even though it has
|
||||
|
||||
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.
|
||||
|
||||
<!-- Add here -->
|
||||
### 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.**
|
||||

|
||||
|
||||
## Troubleshooting
|
||||
<!-- Add here -->
|
||||
@@ -101,8 +112,28 @@ Place `redflag.md` on top of the vault, and restart Obsidian. The most simple wa
|
||||
If there is `redflag.md`, Self-hosted LiveSync suspends all database and storage processes.
|
||||
|
||||
## Tips
|
||||
|
||||
### 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 -->
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
BIN
docs/tweak_mismatch_dialogue.png
Normal file
BIN
docs/tweak_mismatch_dialogue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -8,26 +8,31 @@ 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";
|
||||
const banner = `/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD AND TERSER
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
const dev = process.argv[2] === "dev";
|
||||
|
||||
const keepTest = !prod || dev;
|
||||
|
||||
const terserOpt = {
|
||||
sourceMap: (!prod ? {
|
||||
url: "inline"
|
||||
} : {}),
|
||||
sourceMap: !prod
|
||||
? {
|
||||
url: "inline",
|
||||
}
|
||||
: {},
|
||||
format: {
|
||||
indent_level: 2,
|
||||
beautify: true,
|
||||
comments: "some",
|
||||
ecma: 2018,
|
||||
preamble: banner,
|
||||
webkit: true
|
||||
webkit: true,
|
||||
},
|
||||
parse: {
|
||||
// parse options
|
||||
@@ -52,16 +57,6 @@ const terserOpt = {
|
||||
ecma: 2018,
|
||||
unused: true,
|
||||
},
|
||||
// mangle: {
|
||||
// // mangle options
|
||||
// keep_classnames: true,
|
||||
// keep_fnames: true,
|
||||
|
||||
// properties: {
|
||||
// // mangle property options
|
||||
|
||||
// }
|
||||
// },
|
||||
|
||||
ecma: 2018, // specify one of: 5, 2015, 2016, etc.
|
||||
enclose: false, // or specify true, or "args:values"
|
||||
@@ -71,42 +66,43 @@ const terserOpt = {
|
||||
module: false,
|
||||
// nameCache: null, // or specify a name cache object
|
||||
safari10: false,
|
||||
toplevel: false
|
||||
}
|
||||
|
||||
toplevel: false,
|
||||
};
|
||||
|
||||
const manifestJson = JSON.parse(fs.readFileSync("./manifest.json") + "");
|
||||
const packageJson = JSON.parse(fs.readFileSync("./package.json") + "");
|
||||
const updateInfo = JSON.stringify(fs.readFileSync("./updates.md") + "");
|
||||
|
||||
/** @type esbuild.Plugin[] */
|
||||
const plugins = [{
|
||||
name: 'my-plugin',
|
||||
setup(build) {
|
||||
let count = 0;
|
||||
build.onEnd(async result => {
|
||||
if (count++ === 0) {
|
||||
console.log('first build:', result);
|
||||
|
||||
} else {
|
||||
console.log('subsequent build:');
|
||||
}
|
||||
if (prod) {
|
||||
console.log("Performing terser");
|
||||
const src = fs.readFileSync("./main_org.js").toString();
|
||||
// @ts-ignore
|
||||
const ret = await minify(src, terserOpt);
|
||||
if (ret && ret.code) {
|
||||
fs.writeFileSync("./main.js", ret.code);
|
||||
const plugins = [
|
||||
{
|
||||
name: "my-plugin",
|
||||
setup(build) {
|
||||
let count = 0;
|
||||
build.onEnd(async (result) => {
|
||||
if (count++ === 0) {
|
||||
console.log("first build:", result);
|
||||
} else {
|
||||
console.log("subsequent build:");
|
||||
}
|
||||
console.log("Finished terser");
|
||||
} else {
|
||||
fs.copyFileSync("./main_org.js", "./main.js");
|
||||
}
|
||||
});
|
||||
if (prod) {
|
||||
console.log("Performing terser");
|
||||
const src = fs.readFileSync("./main_org.js").toString();
|
||||
// @ts-ignore
|
||||
const ret = await minify(src, terserOpt);
|
||||
if (ret && ret.code) {
|
||||
fs.writeFileSync("./main.js", ret.code);
|
||||
}
|
||||
console.log("Finished terser");
|
||||
} else {
|
||||
fs.copyFileSync("./main_org.js", "./main.js");
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
}];
|
||||
];
|
||||
|
||||
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: banner,
|
||||
@@ -114,26 +110,12 @@ const context = await esbuild.context({
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
define: {
|
||||
"MANIFEST_VERSION": `"${manifestJson.version}"`,
|
||||
"PACKAGE_VERSION": `"${packageJson.version}"`,
|
||||
"UPDATE_INFO": `${updateInfo}`,
|
||||
"global": "window",
|
||||
MANIFEST_VERSION: `"${manifestJson.version}"`,
|
||||
PACKAGE_VERSION: `"${packageJson.version}"`,
|
||||
UPDATE_INFO: `${updateInfo}`,
|
||||
global: "window",
|
||||
},
|
||||
external: [
|
||||
"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"],
|
||||
external: externals,
|
||||
// minifyWhitespace: true,
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
@@ -142,22 +124,27 @@ const context = await esbuild.context({
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main_org.js",
|
||||
mainFields: ["browser", "module", "main"],
|
||||
minifyWhitespace: false,
|
||||
minifySyntax: false,
|
||||
minifyIdentifiers: false,
|
||||
minify: false,
|
||||
|
||||
dropLabels: prod && !keepTest ? ["TEST", "DEV"] : [],
|
||||
// keepNames: true,
|
||||
plugins: [
|
||||
inlineWorkerPlugin({
|
||||
external: externals,
|
||||
treeShaking: true,
|
||||
}),
|
||||
sveltePlugin({
|
||||
preprocess: sveltePreprocess(),
|
||||
compilerOptions: { css: true, preserveComments: true },
|
||||
compilerOptions: { css: "injected", preserveComments: false },
|
||||
}),
|
||||
...plugins
|
||||
...plugins,
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
if (prod || dev) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
|
||||
BIN
images/devtools1.png
Normal file
BIN
images/devtools1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
images/devtools2.png
Normal file
BIN
images/devtools2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
images/quick_setup_3b.png
Normal file
BIN
images/quick_setup_3b.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.22.11",
|
||||
"version": "0.23.19",
|
||||
"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",
|
||||
|
||||
7457
package-lock.json
generated
7457
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
86
package.json
86
package.json
@@ -1,62 +1,70 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.22.11",
|
||||
"version": "0.23.19",
|
||||
"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": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"buildDev": "node esbuild.config.mjs dev",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "vorotamoroz",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"@types/diff-match-patch": "^1.0.32",
|
||||
"@types/node": "^20.2.5",
|
||||
"@types/pouchdb": "^6.4.0",
|
||||
"@types/pouchdb-adapter-http": "^6.1.3",
|
||||
"@types/pouchdb-adapter-idb": "^6.1.4",
|
||||
"@types/pouchdb-browser": "^6.1.3",
|
||||
"@types/pouchdb-core": "^7.0.11",
|
||||
"@types/pouchdb-mapreduce": "^6.1.7",
|
||||
"@types/pouchdb-replication": "^6.4.4",
|
||||
"@types/transform-pouch": "^1.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "0.18.17",
|
||||
"esbuild-svelte": "^0.7.4",
|
||||
"eslint": "^8.46.0",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/pouchdb": "^6.4.2",
|
||||
"@types/pouchdb-adapter-http": "^6.1.6",
|
||||
"@types/pouchdb-adapter-idb": "^6.1.7",
|
||||
"@types/pouchdb-browser": "^6.1.5",
|
||||
"@types/pouchdb-core": "^7.0.14",
|
||||
"@types/pouchdb-mapreduce": "^6.1.10",
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.16.0",
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "0.23.0",
|
||||
"esbuild-svelte": "^0.8.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.28.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"events": "^3.3.0",
|
||||
"obsidian": "^1.4.11",
|
||||
"postcss": "^8.4.27",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"pouchdb-adapter-http": "^8.0.1",
|
||||
"pouchdb-adapter-idb": "^8.0.1",
|
||||
"pouchdb-adapter-indexeddb": "^8.0.1",
|
||||
"pouchdb-core": "^8.0.1",
|
||||
"pouchdb-errors": "^8.0.1",
|
||||
"pouchdb-find": "^8.0.1",
|
||||
"pouchdb-mapreduce": "^8.0.1",
|
||||
"pouchdb-merge": "^8.0.1",
|
||||
"pouchdb-replication": "^8.0.1",
|
||||
"pouchdb-utils": "^8.0.1",
|
||||
"svelte": "^4.1.2",
|
||||
"svelte-preprocess": "^5.0.4",
|
||||
"terser": "^5.19.2",
|
||||
"obsidian": "^1.5.7",
|
||||
"postcss": "^8.4.39",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
"pouchdb-adapter-indexeddb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
"pouchdb-errors": "^9.0.0",
|
||||
"pouchdb-find": "^9.0.0",
|
||||
"pouchdb-mapreduce": "^9.0.0",
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"svelte": "^4.2.18",
|
||||
"svelte-preprocess": "^6.0.2",
|
||||
"terser": "^5.31.2",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"tslib": "^2.6.1",
|
||||
"typescript": "^5.1.6"
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.621.0",
|
||||
"@smithy/fetch-http-handler": "^3.2.1",
|
||||
"@smithy/protocol-http": "^4.0.3",
|
||||
"@smithy/querystring-builder": "^3.0.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"idb": "^7.1.1",
|
||||
"minimatch": "^9.0.3",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"octagonal-wheels": "^0.1.13",
|
||||
"xxhash-wasm": "0.4.2",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
}
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0,
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"provenance": [],
|
||||
"private_outputs": true,
|
||||
"authorship_tag": "ABX9TyMexQ5pErH5LBG2tENtEVWf",
|
||||
"include_colab_link": true
|
||||
},
|
||||
"kernelspec": {
|
||||
"name": "python3",
|
||||
"display_name": "Python 3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "view-in-github",
|
||||
"colab_type": "text"
|
||||
"colab_type": "text",
|
||||
"id": "view-in-github"
|
||||
},
|
||||
"source": [
|
||||
"<a href=\"https://colab.research.google.com/gist/vrtmrz/9402b101746e08e969b1a4f5f0deb465/setup-flyio-on-the-fly-v2.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>"
|
||||
@@ -29,12 +12,12 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"source": [
|
||||
"- Initial version 7th Feb. 2024"
|
||||
],
|
||||
"metadata": {
|
||||
"id": "AzLlAcLFRO5A"
|
||||
}
|
||||
},
|
||||
"source": [
|
||||
"- Initial version 7th Feb. 2024"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -55,27 +38,32 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"source": [
|
||||
"# Login up sign up\n",
|
||||
"!flyctl auth signup"
|
||||
],
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "mGN08BaFDviy"
|
||||
},
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Login up sign up\n",
|
||||
"!flyctl auth signup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"source": [
|
||||
"Select a region and execute the block."
|
||||
],
|
||||
"metadata": {
|
||||
"id": "BBFTFOP6vA8m"
|
||||
}
|
||||
},
|
||||
"source": [
|
||||
"Select a region and execute the block."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "TNl0A603EF9E"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# see https://fly.io/docs/reference/regions/\n",
|
||||
"region = \"nrt/Tokyo, Japan\" #@param [\"ams/Amsterdam, Netherlands\",\"arn/Stockholm, Sweden\",\"atl/Atlanta, Georgia (US)\",\"bog/Bogotá, Colombia\",\"bos/Boston, Massachusetts (US)\",\"cdg/Paris, France\",\"den/Denver, Colorado (US)\",\"dfw/Dallas, Texas (US)\",\"ewr/Secaucus, NJ (US)\",\"eze/Ezeiza, Argentina\",\"gdl/Guadalajara, Mexico\",\"gig/Rio de Janeiro, Brazil\",\"gru/Sao Paulo, Brazil\",\"hkg/Hong Kong, Hong Kong\",\"iad/Ashburn, Virginia (US)\",\"jnb/Johannesburg, South Africa\",\"lax/Los Angeles, California (US)\",\"lhr/London, United Kingdom\",\"mad/Madrid, Spain\",\"mia/Miami, Florida (US)\",\"nrt/Tokyo, Japan\",\"ord/Chicago, Illinois (US)\",\"otp/Bucharest, Romania\",\"phx/Phoenix, Arizona (US)\",\"qro/Querétaro, Mexico\",\"scl/Santiago, Chile\",\"sea/Seattle, Washington (US)\",\"sin/Singapore, Singapore\",\"sjc/San Jose, California (US)\",\"syd/Sydney, Australia\",\"waw/Warsaw, Poland\",\"yul/Montreal, Canada\",\"yyz/Toronto, Canada\" ] {allow-input: true}\n",
|
||||
@@ -98,31 +86,29 @@
|
||||
" last_line = str.strip(last_line)\n",
|
||||
"\n",
|
||||
"if last_line.startswith(\"obsidian://\"):\n",
|
||||
" result = HTML(f\"Copy your setup-URI with this button! -> <button onclick=\\\"navigator.clipboard.writeText('{last_line}')\\\">Copy setup uri</button><br>Importing passphrase is `welcome`. <br>If you want to synchronise in live mode, please apply a preset after ensuring the imported configuration works.\")\n",
|
||||
" result = HTML(f\"Copy your setup-URI with this button! -> <button onclick=\\\"navigator.clipboard.writeText('{last_line}')\\\">Copy setup uri</button><br>Importing passphrase is displayed one. <br>If you want to synchronise in live mode, please apply a preset after ensuring the imported configuration works.\")\n",
|
||||
"else:\n",
|
||||
" result = \"Failed to encrypt the setup URI\"\n",
|
||||
"result"
|
||||
],
|
||||
"metadata": {
|
||||
"id": "TNl0A603EF9E"
|
||||
},
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "oeIzExnEKhFp"
|
||||
},
|
||||
"source": [
|
||||
"If you see the `Copy setup URI` button, Congratulations! Your CouchDB is ready to use! Please click the button. And open this on Obsidian.\n",
|
||||
"\n",
|
||||
"And, you should keep the output to your secret memo.\n",
|
||||
"\n"
|
||||
],
|
||||
"metadata": {
|
||||
"id": "oeIzExnEKhFp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "sdQrqOjERN3K"
|
||||
},
|
||||
"source": [
|
||||
"\n",
|
||||
"\n",
|
||||
@@ -131,21 +117,35 @@
|
||||
"\n",
|
||||
"If you want to delete this CouchDB instance, you can do it by executing next cell. \n",
|
||||
"If your fly.toml has been gone, access https://fly.io/dashboard and check the existing app."
|
||||
],
|
||||
"metadata": {
|
||||
"id": "sdQrqOjERN3K"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"source": [
|
||||
"!./delete-server.sh"
|
||||
],
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "7JMSkNvVIIfg"
|
||||
},
|
||||
"execution_count": null,
|
||||
"outputs": []
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"!./delete-server.sh"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"authorship_tag": "ABX9TyMexQ5pErH5LBG2tENtEVWf",
|
||||
"include_colab_link": true,
|
||||
"private_outputs": true,
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||
|
||||
@@ -1,843 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { Notice, type PluginManifest, parseYaml, normalizePath, type ListedFiles } from "./deps";
|
||||
|
||||
import type { EntryDoc, LoadedEntry, InternalFileEntry, FilePathWithPrefix, FilePath, DocumentID, AnyEntry, SavingEntry } from "./lib/src/types";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, MODE_SELECTIVE } from "./lib/src/types";
|
||||
import { ICXHeader, PERIODIC_PLUGIN_SWEEP, } from "./types";
|
||||
import { createTextBlob, delay, getDocData, isDocContentSame, sendSignal, waitForSignal } from "./lib/src/utils";
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import { WrappedNotice } from "./lib/src/wrapper";
|
||||
import { readString, decodeBinary, arrayBufferToBase64, sha1 } from "./lib/src/strbin";
|
||||
import { serialized } from "./lib/src/lock";
|
||||
import { LiveSyncCommands } from "./LiveSyncCommands";
|
||||
import { stripAllPrefixes } from "./lib/src/path";
|
||||
import { PeriodicProcessor, askYesNo, disposeMemoObject, memoIfNotExist, memoObject, retrieveMemoObject, scheduleTask } from "./utils";
|
||||
import { PluginDialogModal } from "./dialogs";
|
||||
import { JsonResolveModal } from "./JsonResolveModal";
|
||||
import { QueueProcessor } from './lib/src/processor';
|
||||
import { pluginScanningCount } from './lib/src/stores';
|
||||
import type ObsidianLiveSyncPlugin from './main';
|
||||
|
||||
const d = "\u200b";
|
||||
const d2 = "\n";
|
||||
|
||||
function serialize(data: PluginDataEx): string {
|
||||
// For higher performance, create custom plug-in data strings.
|
||||
// Self-hosted LiveSync uses `\n` to split chunks. Therefore, grouping together those with similar entropy would work nicely.
|
||||
let ret = "";
|
||||
ret += ":";
|
||||
ret += data.category + d + data.name + d + data.term + d2;
|
||||
ret += (data.version ?? "") + d2;
|
||||
ret += data.mtime + d2;
|
||||
for (const file of data.files) {
|
||||
ret += file.filename + d + (file.displayName ?? "") + d + (file.version ?? "") + d2;
|
||||
ret += file.mtime + d + file.size + d2;
|
||||
for (const data of file.data ?? []) {
|
||||
ret += data + d
|
||||
}
|
||||
ret += d2;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
function fetchToken(source: string, from: number): [next: number, token: string] {
|
||||
const limitIdx = source.indexOf(d2, from);
|
||||
const limit = limitIdx == -1 ? source.length : limitIdx;
|
||||
const delimiterIdx = source.indexOf(d, from);
|
||||
const delimiter = delimiterIdx == -1 ? source.length : delimiterIdx;
|
||||
const tokenEnd = Math.min(limit, delimiter);
|
||||
let next = tokenEnd;
|
||||
if (limit < delimiter) {
|
||||
next = tokenEnd;
|
||||
} else {
|
||||
next = tokenEnd + 1
|
||||
}
|
||||
return [next, source.substring(from, tokenEnd)];
|
||||
}
|
||||
function getTokenizer(source: string) {
|
||||
const t = {
|
||||
pos: 1,
|
||||
next() {
|
||||
const [next, token] = fetchToken(source, this.pos);
|
||||
this.pos = next;
|
||||
return token;
|
||||
},
|
||||
nextLine() {
|
||||
const nextPos = source.indexOf(d2, this.pos);
|
||||
if (nextPos == -1) {
|
||||
this.pos = source.length;
|
||||
} else {
|
||||
this.pos = nextPos + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function deserialize2(str: string): PluginDataEx {
|
||||
const tokens = getTokenizer(str);
|
||||
const ret = {} as PluginDataEx;
|
||||
const category = tokens.next();
|
||||
const name = tokens.next();
|
||||
const term = tokens.next();
|
||||
tokens.nextLine();
|
||||
const version = tokens.next();
|
||||
tokens.nextLine();
|
||||
const mtime = Number(tokens.next());
|
||||
tokens.nextLine();
|
||||
const result: PluginDataEx = Object.assign(ret,
|
||||
{ category, name, term, version, mtime, files: [] as PluginDataExFile[] })
|
||||
let filename = "";
|
||||
do {
|
||||
filename = tokens.next();
|
||||
if (!filename) break;
|
||||
const displayName = tokens.next();
|
||||
const version = tokens.next();
|
||||
tokens.nextLine();
|
||||
const mtime = Number(tokens.next());
|
||||
const size = Number(tokens.next());
|
||||
tokens.nextLine();
|
||||
const data = [] as string[];
|
||||
let piece = "";
|
||||
do {
|
||||
piece = tokens.next();
|
||||
if (piece == "") break;
|
||||
data.push(piece);
|
||||
} while (piece != "");
|
||||
result.files.push(
|
||||
{
|
||||
filename,
|
||||
displayName,
|
||||
version,
|
||||
mtime,
|
||||
size,
|
||||
data
|
||||
}
|
||||
)
|
||||
tokens.nextLine();
|
||||
} while (filename);
|
||||
return result;
|
||||
}
|
||||
|
||||
function deserialize<T>(str: string, def: T) {
|
||||
try {
|
||||
if (str[0] == ":") return deserialize2(str);
|
||||
return JSON.parse(str) as T;
|
||||
} catch (ex) {
|
||||
try {
|
||||
return parseYaml(str);
|
||||
} catch (ex) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const pluginList = writable([] as PluginDataExDisplay[]);
|
||||
export const pluginIsEnumerating = writable(false);
|
||||
|
||||
export type PluginDataExFile = {
|
||||
filename: string,
|
||||
data?: string[],
|
||||
mtime: number,
|
||||
size: number,
|
||||
version?: string,
|
||||
displayName?: string,
|
||||
}
|
||||
export type PluginDataExDisplay = {
|
||||
documentPath: FilePathWithPrefix,
|
||||
category: string,
|
||||
name: string,
|
||||
term: string,
|
||||
displayName?: string,
|
||||
files: PluginDataExFile[],
|
||||
version?: string,
|
||||
mtime: number,
|
||||
}
|
||||
export type PluginDataEx = {
|
||||
documentPath?: FilePathWithPrefix,
|
||||
category: string,
|
||||
name: string,
|
||||
displayName?: string,
|
||||
term: string,
|
||||
files: PluginDataExFile[],
|
||||
version?: string,
|
||||
mtime: number,
|
||||
};
|
||||
export class ConfigSync extends LiveSyncCommands {
|
||||
constructor(plugin: ObsidianLiveSyncPlugin) {
|
||||
super(plugin);
|
||||
pluginScanningCount.onChanged((e) => {
|
||||
const total = e.value;
|
||||
pluginIsEnumerating.set(total != 0);
|
||||
if (total == 0) {
|
||||
Logger(`Processing configurations done`, LOG_LEVEL_INFO, "get-plugins");
|
||||
}
|
||||
})
|
||||
}
|
||||
confirmPopup: WrappedNotice = null;
|
||||
get kvDB() {
|
||||
return this.plugin.kvDB;
|
||||
}
|
||||
|
||||
pluginDialog: PluginDialogModal = null;
|
||||
periodicPluginSweepProcessor = new PeriodicProcessor(this.plugin, async () => await this.scanAllConfigFiles(false));
|
||||
|
||||
pluginList: PluginDataExDisplay[] = [];
|
||||
showPluginSyncModal() {
|
||||
if (!this.settings.usePluginSync) {
|
||||
return;
|
||||
}
|
||||
if (this.pluginDialog != null) {
|
||||
this.pluginDialog.open();
|
||||
} else {
|
||||
this.pluginDialog = new PluginDialogModal(this.app, this.plugin);
|
||||
this.pluginDialog.open();
|
||||
}
|
||||
}
|
||||
|
||||
hidePluginSyncModal() {
|
||||
if (this.pluginDialog != null) {
|
||||
this.pluginDialog.close();
|
||||
this.pluginDialog = null;
|
||||
}
|
||||
}
|
||||
onunload() {
|
||||
this.hidePluginSyncModal();
|
||||
this.periodicPluginSweepProcessor?.disable();
|
||||
}
|
||||
onload() {
|
||||
this.plugin.addCommand({
|
||||
id: "livesync-plugin-dialog-ex",
|
||||
name: "Show customization sync dialog",
|
||||
callback: () => {
|
||||
this.showPluginSyncModal();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getFileCategory(filePath: string): "CONFIG" | "THEME" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_ETC" | "PLUGIN_DATA" | "" {
|
||||
if (filePath.split("/").length == 2 && filePath.endsWith(".json")) return "CONFIG";
|
||||
if (filePath.split("/").length == 4 && filePath.startsWith(`${this.app.vault.configDir}/themes/`)) return "THEME";
|
||||
if (filePath.startsWith(`${this.app.vault.configDir}/snippets/`) && filePath.endsWith(".css")) return "SNIPPET";
|
||||
if (filePath.startsWith(`${this.app.vault.configDir}/plugins/`)) {
|
||||
if (filePath.endsWith("/styles.css") || filePath.endsWith("/manifest.json") || filePath.endsWith("/main.js")) {
|
||||
return "PLUGIN_MAIN";
|
||||
} else if (filePath.endsWith("/data.json")) {
|
||||
return "PLUGIN_DATA";
|
||||
} else {
|
||||
//TODO: to be configurable.
|
||||
// With algorithm which implemented at v0.19.0, is too heavy.
|
||||
return "";
|
||||
// return "PLUGIN_ETC";
|
||||
}
|
||||
// return "PLUGIN";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
isTargetPath(filePath: string): boolean {
|
||||
if (!filePath.startsWith(this.app.vault.configDir)) return false;
|
||||
// Idea non-filter option?
|
||||
return this.getFileCategory(filePath) != "";
|
||||
}
|
||||
async onInitializeDatabase(showNotice: boolean) {
|
||||
if (this.settings.usePluginSync) {
|
||||
try {
|
||||
Logger("Scanning customizations...");
|
||||
await this.scanAllConfigFiles(showNotice);
|
||||
Logger("Scanning customizations : done");
|
||||
} catch (ex) {
|
||||
Logger("Scanning customizations : failed");
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
async beforeReplicate(showNotice: boolean) {
|
||||
if (this.settings.autoSweepPlugins && this.settings.usePluginSync) {
|
||||
await this.scanAllConfigFiles(showNotice);
|
||||
}
|
||||
}
|
||||
async onResume() {
|
||||
if (this.plugin.suspended) {
|
||||
return;
|
||||
}
|
||||
if (this.settings.autoSweepPlugins && this.settings.usePluginSync) {
|
||||
await this.scanAllConfigFiles(false);
|
||||
}
|
||||
this.periodicPluginSweepProcessor.enable(this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges ? (PERIODIC_PLUGIN_SWEEP * 1000) : 0);
|
||||
|
||||
}
|
||||
async reloadPluginList(showMessage: boolean) {
|
||||
this.pluginList = [];
|
||||
pluginList.set(this.pluginList)
|
||||
await this.updatePluginList(showMessage);
|
||||
}
|
||||
async loadPluginData(path: FilePathWithPrefix): Promise<PluginDataExDisplay | false> {
|
||||
const wx = await this.localDatabase.getDBEntry(path, null, false, false);
|
||||
if (wx) {
|
||||
const data = deserialize(getDocData(wx.data), {}) as PluginDataEx;
|
||||
const xFiles = [] as PluginDataExFile[];
|
||||
for (const file of data.files) {
|
||||
const work = { ...file };
|
||||
const tempStr = getDocData(work.data);
|
||||
work.data = [await sha1(tempStr)];
|
||||
xFiles.push(work);
|
||||
}
|
||||
return ({
|
||||
...data,
|
||||
documentPath: this.getPath(wx),
|
||||
files: xFiles
|
||||
}) as PluginDataExDisplay;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
createMissingConfigurationEntry() {
|
||||
let saveRequired = false;
|
||||
for (const v of this.pluginList) {
|
||||
const key = `${v.category}/${v.name}`;
|
||||
if (!(key in this.plugin.settings.pluginSyncExtendedSetting)) {
|
||||
this.plugin.settings.pluginSyncExtendedSetting[key] = {
|
||||
key,
|
||||
mode: MODE_SELECTIVE,
|
||||
files: []
|
||||
}
|
||||
}
|
||||
if (this.plugin.settings.pluginSyncExtendedSetting[key].files.sort().join(",").toLowerCase() !=
|
||||
v.files.map(e => e.filename).sort().join(",").toLowerCase()) {
|
||||
this.plugin.settings.pluginSyncExtendedSetting[key].files = v.files.map(e => e.filename).sort();
|
||||
saveRequired = true;
|
||||
}
|
||||
}
|
||||
if (saveRequired) {
|
||||
this.plugin.saveSettingData();
|
||||
}
|
||||
}
|
||||
|
||||
pluginScanProcessor = new QueueProcessor(async (v: AnyEntry[]) => {
|
||||
const plugin = v[0];
|
||||
const path = plugin.path || this.getPath(plugin);
|
||||
const oldEntry = (this.pluginList.find(e => e.documentPath == path));
|
||||
if (oldEntry && oldEntry.mtime == plugin.mtime) return;
|
||||
try {
|
||||
const pluginData = await this.loadPluginData(path);
|
||||
if (pluginData) {
|
||||
return [pluginData];
|
||||
}
|
||||
// Failed to load
|
||||
return;
|
||||
|
||||
} catch (ex) {
|
||||
Logger(`Something happened at enumerating customization :${path}`, LOG_LEVEL_NOTICE);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return;
|
||||
}, { suspended: true, batchSize: 1, concurrentLimit: 5, delay: 300, yieldThreshold: 10 }).pipeTo(
|
||||
new QueueProcessor(
|
||||
async (pluginDataList) => {
|
||||
// Concurrency is two, therefore, we can unlock the previous awaiting.
|
||||
sendSignal("plugin-next-load");
|
||||
let newList = [...this.pluginList];
|
||||
for (const item of pluginDataList) {
|
||||
newList = newList.filter(x => x.documentPath != item.documentPath);
|
||||
newList.push(item)
|
||||
}
|
||||
this.pluginList = newList;
|
||||
pluginList.set(newList);
|
||||
if (pluginDataList.length != 10) {
|
||||
// If the queue is going to be empty, await subsequent for a while.
|
||||
await waitForSignal("plugin-next-load", 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
, { suspended: true, batchSize: 10, concurrentLimit: 2, delay: 250, yieldThreshold: 25, totalRemainingReactiveSource: pluginScanningCount })).startPipeline().root.onIdle(() => {
|
||||
Logger(`All files enumerated`, LOG_LEVEL_INFO, "get-plugins");
|
||||
this.createMissingConfigurationEntry();
|
||||
});
|
||||
|
||||
|
||||
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
||||
// pluginList.set([]);
|
||||
if (!this.settings.usePluginSync) {
|
||||
this.pluginScanProcessor.clearQueue();
|
||||
this.pluginList = [];
|
||||
pluginList.set(this.pluginList)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updatedDocumentId = updatedDocumentPath ? await this.path2id(updatedDocumentPath) : "";
|
||||
const plugins = updatedDocumentPath ?
|
||||
this.localDatabase.findEntries(updatedDocumentId, updatedDocumentId + "\u{10ffff}", { include_docs: true, key: updatedDocumentId, limit: 1 }) :
|
||||
this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
||||
for await (const v of plugins) {
|
||||
const path = v.path || this.getPath(v);
|
||||
if (updatedDocumentPath && updatedDocumentPath != path) continue;
|
||||
this.pluginScanProcessor.enqueue(v);
|
||||
}
|
||||
} finally {
|
||||
pluginIsEnumerating.set(false);
|
||||
}
|
||||
pluginIsEnumerating.set(false);
|
||||
// return entries;
|
||||
}
|
||||
async compareUsingDisplayData(dataA: PluginDataExDisplay, dataB: PluginDataExDisplay) {
|
||||
const docA = await this.localDatabase.getDBEntry(dataA.documentPath);
|
||||
const docB = await this.localDatabase.getDBEntry(dataB.documentPath);
|
||||
|
||||
if (docA && docB) {
|
||||
const pluginDataA = deserialize(getDocData(docA.data), {}) as PluginDataEx;
|
||||
pluginDataA.documentPath = dataA.documentPath;
|
||||
const pluginDataB = deserialize(getDocData(docB.data), {}) as PluginDataEx;
|
||||
pluginDataB.documentPath = dataB.documentPath;
|
||||
|
||||
// Use outer structure to wrap each data.
|
||||
return await this.showJSONMergeDialogAndMerge(docA, docB, pluginDataA, pluginDataB);
|
||||
|
||||
}
|
||||
return false;
|
||||
}
|
||||
showJSONMergeDialogAndMerge(docA: LoadedEntry, docB: LoadedEntry, pluginDataA: PluginDataEx, pluginDataB: PluginDataEx): Promise<boolean> {
|
||||
const fileA = { ...pluginDataA.files[0], ctime: pluginDataA.files[0].mtime, _id: `${pluginDataA.documentPath}` as DocumentID };
|
||||
const fileB = pluginDataB.files[0];
|
||||
const docAx = { ...docA, ...fileA } as LoadedEntry, docBx = { ...docB, ...fileB } as LoadedEntry
|
||||
return serialized("config:merge-data", () => new Promise((res) => {
|
||||
Logger("Opening data-merging dialog", LOG_LEVEL_VERBOSE);
|
||||
// const docs = [docA, docB];
|
||||
const path = stripAllPrefixes(docAx.path.split("/").slice(-1).join("/") as FilePath);
|
||||
const modal = new JsonResolveModal(this.app, path, [docAx, docBx], async (keep, result) => {
|
||||
if (result == null) return res(false);
|
||||
try {
|
||||
res(await this.applyData(pluginDataA, result));
|
||||
} catch (ex) {
|
||||
Logger("Could not apply merged file");
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
res(false);
|
||||
}
|
||||
}, "📡", "🛰️", "B");
|
||||
modal.open();
|
||||
}));
|
||||
}
|
||||
async applyData(data: PluginDataEx, content?: string): Promise<boolean> {
|
||||
Logger(`Applying ${data.displayName || data.name}..`);
|
||||
const baseDir = this.app.vault.configDir;
|
||||
try {
|
||||
if (!data.documentPath) throw "InternalError: Document path not exist";
|
||||
const dx = await this.localDatabase.getDBEntry(data.documentPath);
|
||||
if (dx == false) {
|
||||
throw "Not found on database"
|
||||
}
|
||||
const loadedData = deserialize(getDocData(dx.data), {}) as PluginDataEx;
|
||||
for (const f of loadedData.files) {
|
||||
Logger(`Applying ${f.filename} of ${data.displayName || data.name}..`);
|
||||
try {
|
||||
// console.dir(f);
|
||||
const path = `${baseDir}/${f.filename}`;
|
||||
await this.vaultAccess.ensureDirectory(path);
|
||||
if (!content) {
|
||||
const dt = decodeBinary(f.data);
|
||||
await this.vaultAccess.adapterWrite(path, dt);
|
||||
} else {
|
||||
await this.vaultAccess.adapterWrite(path, content);
|
||||
}
|
||||
Logger(`Applying ${f.filename} of ${data.displayName || data.name}.. Done`);
|
||||
|
||||
} catch (ex) {
|
||||
Logger(`Applying ${f.filename} of ${data.displayName || data.name}.. Failed`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
|
||||
}
|
||||
const uPath = `${baseDir}/${loadedData.files[0].filename}` as FilePath;
|
||||
await this.storeCustomizationFiles(uPath);
|
||||
await this.updatePluginList(true, uPath);
|
||||
await delay(100);
|
||||
Logger(`Config ${data.displayName || data.name} has been applied`, LOG_LEVEL_NOTICE);
|
||||
if (data.category == "PLUGIN_DATA" || data.category == "PLUGIN_MAIN") {
|
||||
//@ts-ignore
|
||||
const manifests = Object.values(this.app.plugins.manifests) as any as PluginManifest[];
|
||||
//@ts-ignore
|
||||
const enabledPlugins = this.app.plugins.enabledPlugins as Set<string>;
|
||||
const pluginManifest = manifests.find((manifest) => enabledPlugins.has(manifest.id) && manifest.dir == `${baseDir}/plugins/${data.name}`);
|
||||
if (pluginManifest) {
|
||||
Logger(`Unloading plugin: ${pluginManifest.name}`, LOG_LEVEL_NOTICE, "plugin-reload-" + pluginManifest.id);
|
||||
// @ts-ignore
|
||||
await this.app.plugins.unloadPlugin(pluginManifest.id);
|
||||
// @ts-ignore
|
||||
await this.app.plugins.loadPlugin(pluginManifest.id);
|
||||
Logger(`Plugin reloaded: ${pluginManifest.name}`, LOG_LEVEL_NOTICE, "plugin-reload-" + pluginManifest.id);
|
||||
}
|
||||
} else if (data.category == "CONFIG") {
|
||||
scheduleTask("configReload", 250, async () => {
|
||||
if (await askYesNo(this.app, "Do you want to restart and reload Obsidian now?") == "yes") {
|
||||
// @ts-ignore
|
||||
this.app.commands.executeCommandById("app:reload")
|
||||
}
|
||||
})
|
||||
}
|
||||
return true;
|
||||
} catch (ex) {
|
||||
Logger(`Applying ${data.displayName || data.name}.. Failed`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async deleteData(data: PluginDataEx): Promise<boolean> {
|
||||
try {
|
||||
if (data.documentPath) {
|
||||
await this.deleteConfigOnDatabase(data.documentPath);
|
||||
await this.updatePluginList(false, data.documentPath);
|
||||
Logger(`Delete: ${data.documentPath}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return true;
|
||||
} catch (ex) {
|
||||
Logger(`Failed to delete: ${data.documentPath}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
|
||||
}
|
||||
}
|
||||
async parseReplicationResultItem(docs: PouchDB.Core.ExistingDocument<EntryDoc>) {
|
||||
if (docs._id.startsWith(ICXHeader)) {
|
||||
if (this.plugin.settings.usePluginSync) {
|
||||
await this.updatePluginList(false, (docs as AnyEntry).path ? (docs as AnyEntry).path : this.getPath((docs as AnyEntry)));
|
||||
}
|
||||
if (this.plugin.settings.usePluginSync && this.plugin.settings.notifyPluginOrSettingUpdated) {
|
||||
if (!this.pluginDialog || (this.pluginDialog && !this.pluginDialog.isOpened())) {
|
||||
const fragment = createFragment((doc) => {
|
||||
doc.createEl("span", null, (a) => {
|
||||
a.appendText(`Some configuration has been arrived, Press `);
|
||||
a.appendChild(a.createEl("a", null, (anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
this.showPluginSyncModal();
|
||||
});
|
||||
}));
|
||||
|
||||
a.appendText(` to open the config sync dialog , or press elsewhere to dismiss this message.`);
|
||||
});
|
||||
});
|
||||
|
||||
const updatedPluginKey = "popupUpdated-plugins";
|
||||
scheduleTask(updatedPluginKey, 1000, async () => {
|
||||
const popup = await memoIfNotExist(updatedPluginKey, () => new Notice(fragment, 0));
|
||||
//@ts-ignore
|
||||
const isShown = popup?.noticeEl?.isShown();
|
||||
if (!isShown) {
|
||||
memoObject(updatedPluginKey, new Notice(fragment, 0));
|
||||
}
|
||||
scheduleTask(updatedPluginKey + "-close", 20000, () => {
|
||||
const popup = retrieveMemoObject<Notice>(updatedPluginKey);
|
||||
if (!popup)
|
||||
return;
|
||||
//@ts-ignore
|
||||
if (popup?.noticeEl?.isShown()) {
|
||||
popup.hide();
|
||||
}
|
||||
disposeMemoObject(updatedPluginKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async realizeSettingSyncMode(): Promise<void> {
|
||||
this.periodicPluginSweepProcessor?.disable();
|
||||
if (this.plugin.suspended)
|
||||
return;
|
||||
if (!this.settings.usePluginSync) {
|
||||
return;
|
||||
}
|
||||
if (this.settings.autoSweepPlugins) {
|
||||
await this.scanAllConfigFiles(false);
|
||||
}
|
||||
this.periodicPluginSweepProcessor.enable(this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges ? (PERIODIC_PLUGIN_SWEEP * 1000) : 0);
|
||||
return;
|
||||
}
|
||||
|
||||
recentProcessedInternalFiles = [] as string[];
|
||||
async makeEntryFromFile(path: FilePath): Promise<false | PluginDataExFile> {
|
||||
const stat = await this.vaultAccess.adapterStat(path);
|
||||
let version: string | undefined;
|
||||
let displayName: string | undefined;
|
||||
if (!stat) {
|
||||
return false;
|
||||
}
|
||||
const contentBin = await this.vaultAccess.adapterReadBinary(path);
|
||||
let content: string[];
|
||||
try {
|
||||
content = await arrayBufferToBase64(contentBin);
|
||||
if (path.toLowerCase().endsWith("/manifest.json")) {
|
||||
const v = readString(new Uint8Array(contentBin));
|
||||
try {
|
||||
const json = JSON.parse(v);
|
||||
if ("version" in json) {
|
||||
version = `${json.version}`;
|
||||
}
|
||||
if ("name" in json) {
|
||||
displayName = `${json.name}`;
|
||||
}
|
||||
} catch (ex) {
|
||||
Logger(`Configuration sync data: ${path} looks like manifest, but could not read the version`, LOG_LEVEL_INFO);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
Logger(`The file ${path} could not be encoded`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const mtime = stat.mtime;
|
||||
return {
|
||||
filename: path.substring(this.app.vault.configDir.length + 1),
|
||||
data: content,
|
||||
mtime,
|
||||
size: stat.size,
|
||||
version,
|
||||
displayName: displayName,
|
||||
}
|
||||
}
|
||||
|
||||
filenameToUnifiedKey(path: string, termOverRide?: string) {
|
||||
const term = termOverRide || this.plugin.deviceAndVaultName;
|
||||
const category = this.getFileCategory(path);
|
||||
const name = (category == "CONFIG" || category == "SNIPPET") ?
|
||||
(path.split("/").slice(-1)[0]) :
|
||||
(category == "PLUGIN_ETC" ?
|
||||
path.split("/").slice(-2).join("/") :
|
||||
path.split("/").slice(-2)[0]);
|
||||
return `${ICXHeader}${term}/${category}/${name}.md` as FilePathWithPrefix
|
||||
}
|
||||
async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
|
||||
const term = termOverRide || this.plugin.deviceAndVaultName;
|
||||
if (term == "") {
|
||||
Logger("We have to configure the device name", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const vf = this.filenameToUnifiedKey(path, term);
|
||||
return await serialized(`plugin-${vf}`, async () => {
|
||||
const category = this.getFileCategory(path);
|
||||
let mtime = 0;
|
||||
let fileTargets = [] as FilePath[];
|
||||
// let savePath = "";
|
||||
const name = (category == "CONFIG" || category == "SNIPPET") ?
|
||||
(path.split("/").reverse()[0]) :
|
||||
(path.split("/").reverse()[1]);
|
||||
const parentPath = path.split("/").slice(0, -1).join("/");
|
||||
const prefixedFileName = this.filenameToUnifiedKey(path, term);
|
||||
const id = await this.path2id(prefixedFileName);
|
||||
const dt: PluginDataEx = {
|
||||
category: category,
|
||||
files: [],
|
||||
name: name,
|
||||
mtime: 0,
|
||||
term: term
|
||||
}
|
||||
// let scheduleKey = "";
|
||||
if (category == "CONFIG" || category == "SNIPPET" || category == "PLUGIN_ETC" || category == "PLUGIN_DATA") {
|
||||
fileTargets = [path];
|
||||
if (category == "PLUGIN_ETC") {
|
||||
dt.displayName = path.split("/").slice(-1).join("/");
|
||||
}
|
||||
} else if (category == "PLUGIN_MAIN") {
|
||||
fileTargets = ["manifest.json", "main.js", "styles.css"].map(e => `${parentPath}/${e}` as FilePath);
|
||||
} else if (category == "THEME") {
|
||||
fileTargets = ["manifest.json", "theme.css"].map(e => `${parentPath}/${e}` as FilePath);
|
||||
}
|
||||
for (const target of fileTargets) {
|
||||
const data = await this.makeEntryFromFile(target);
|
||||
if (data == false) {
|
||||
// Logger(`Config: skipped: ${target} `, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
if (data.version) {
|
||||
dt.version = data.version;
|
||||
}
|
||||
if (data.displayName) {
|
||||
dt.displayName = data.displayName;
|
||||
}
|
||||
// Use average for total modified time.
|
||||
mtime = mtime == 0 ? data.mtime : ((data.mtime + mtime) / 2);
|
||||
dt.files.push(data);
|
||||
}
|
||||
dt.mtime = mtime;
|
||||
|
||||
// Logger(`Configuration saving: ${prefixedFileName}`);
|
||||
if (dt.files.length == 0) {
|
||||
Logger(`Nothing left: deleting.. ${path}`);
|
||||
await this.deleteConfigOnDatabase(prefixedFileName);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
return
|
||||
}
|
||||
|
||||
const content = createTextBlob(serialize(dt));
|
||||
try {
|
||||
const old = await this.localDatabase.getDBEntryMeta(prefixedFileName, null, false);
|
||||
let saveData: SavingEntry;
|
||||
if (old === false) {
|
||||
saveData = {
|
||||
_id: id,
|
||||
path: prefixedFileName,
|
||||
data: content,
|
||||
mtime,
|
||||
ctime: mtime,
|
||||
datatype: "newnote",
|
||||
size: content.size,
|
||||
children: [],
|
||||
deleted: false,
|
||||
type: "newnote",
|
||||
};
|
||||
} else {
|
||||
if (old.mtime == mtime) {
|
||||
// Logger(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
const oldC = await this.localDatabase.getDBEntryFromMeta(old, {}, false, false);
|
||||
if (oldC) {
|
||||
const d = await deserialize(getDocData(oldC.data), {}) as PluginDataEx;
|
||||
const diffs = (d.files.map(previous => ({ prev: previous, curr: dt.files.find(e => e.filename == previous.filename) })).map(async e => {
|
||||
try { return await isDocContentSame(e.curr.data, e.prev.data) } catch (_) { return false }
|
||||
}))
|
||||
const isSame = (await Promise.all(diffs)).every(e => e == true);
|
||||
if (isSame) {
|
||||
Logger(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
saveData =
|
||||
{
|
||||
...old,
|
||||
data: content,
|
||||
mtime,
|
||||
size: content.size,
|
||||
datatype: "newnote",
|
||||
children: [],
|
||||
deleted: false,
|
||||
type: "newnote",
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
await this.updatePluginList(false, saveData.path);
|
||||
Logger(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
Logger(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
async watchVaultRawEventsAsync(path: FilePath) {
|
||||
if (!this.settings.usePluginSync) return false;
|
||||
if (!this.isTargetPath(path)) return false;
|
||||
const stat = await this.vaultAccess.adapterStat(path);
|
||||
// Make sure that target is a file.
|
||||
if (stat && stat.type != "file")
|
||||
return false;
|
||||
|
||||
const configDir = normalizePath(this.app.vault.configDir);
|
||||
const synchronisedInConfigSync = Object.values(this.settings.pluginSyncExtendedSetting).filter(e => e.mode != MODE_SELECTIVE).map(e => e.files).flat().map(e => `${configDir}/${e}`.toLowerCase());
|
||||
if (synchronisedInConfigSync.some(e => e.startsWith(path.toLowerCase()))) {
|
||||
Logger(`Customization file skipped: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const storageMTime = ~~((stat && stat.mtime || 0) / 1000);
|
||||
const key = `${path}-${storageMTime}`;
|
||||
if (this.recentProcessedInternalFiles.contains(key)) {
|
||||
// If recently processed, it may caused by self.
|
||||
return true;
|
||||
}
|
||||
this.recentProcessedInternalFiles = [key, ...this.recentProcessedInternalFiles].slice(0, 100);
|
||||
|
||||
this.storeCustomizationFiles(path).then(() => {/* Fire and forget */ });
|
||||
|
||||
}
|
||||
|
||||
|
||||
async scanAllConfigFiles(showMessage: boolean) {
|
||||
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
Logger("Scanning customizing files.", logLevel, "scan-all-config");
|
||||
const term = this.plugin.deviceAndVaultName;
|
||||
if (term == "") {
|
||||
Logger("We have to configure the device name", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const filesAll = await this.scanInternalFiles();
|
||||
const files = filesAll.filter(e => this.isTargetPath(e)).map(e => ({ key: this.filenameToUnifiedKey(e), file: e }));
|
||||
const virtualPathsOfLocalFiles = [...new Set(files.map(e => e.key))];
|
||||
const filesOnDB = ((await this.localDatabase.allDocsRaw({ startkey: ICXHeader + "", endkey: `${ICXHeader}\u{10ffff}`, include_docs: true })).rows.map(e => e.doc) as InternalFileEntry[]).filter(e => !e.deleted);
|
||||
let deleteCandidate = filesOnDB.map(e => this.getPath(e)).filter(e => e.startsWith(`${ICXHeader}${term}/`));
|
||||
for (const vp of virtualPathsOfLocalFiles) {
|
||||
const p = files.find(e => e.key == vp).file;
|
||||
await this.storeCustomizationFiles(p);
|
||||
deleteCandidate = deleteCandidate.filter(e => e != vp);
|
||||
}
|
||||
for (const vp of deleteCandidate) {
|
||||
await this.deleteConfigOnDatabase(vp);
|
||||
}
|
||||
this.updatePluginList(false).then(/* fire and forget */);
|
||||
}
|
||||
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
|
||||
|
||||
// const id = await this.path2id(prefixedFileName);
|
||||
const mtime = new Date().getTime();
|
||||
await serialized("file-x-" + prefixedFileName, async () => {
|
||||
try {
|
||||
const old = await this.localDatabase.getDBEntryMeta(prefixedFileName, null, false) as InternalFileEntry | false;
|
||||
let saveData: InternalFileEntry;
|
||||
if (old === false) {
|
||||
Logger(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted (Not found on database)`);
|
||||
} else {
|
||||
if (old.deleted) {
|
||||
Logger(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted`);
|
||||
return;
|
||||
}
|
||||
saveData =
|
||||
{
|
||||
...old,
|
||||
mtime,
|
||||
size: 0,
|
||||
children: [],
|
||||
deleted: true,
|
||||
type: "newnote",
|
||||
};
|
||||
}
|
||||
await this.localDatabase.putRaw(saveData);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
Logger(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
|
||||
} catch (ex) {
|
||||
Logger(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async scanInternalFiles(): Promise<FilePath[]> {
|
||||
const filenames = (await this.getFiles(this.app.vault.configDir, 2)).filter(e => e.startsWith(".")).filter(e => !e.startsWith(".trash"));
|
||||
return filenames as FilePath[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
async getFiles(
|
||||
path: string,
|
||||
lastDepth: number
|
||||
) {
|
||||
if (lastDepth == -1) return [];
|
||||
let w: ListedFiles;
|
||||
try {
|
||||
w = await this.app.vault.adapter.list(path);
|
||||
} catch (ex) {
|
||||
Logger(`Could not traverse(ConfigSync):${path}`, LOG_LEVEL_INFO);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
return [];
|
||||
}
|
||||
let files = [
|
||||
...w.files
|
||||
];
|
||||
for (const v of w.folders) {
|
||||
files = files.concat(await this.getFiles(v, lastDepth - 1));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import { normalizePath, type PluginManifest } from "./deps";
|
||||
import type { DocumentID, EntryDoc, FilePathWithPrefix, LoadedEntry, SavingEntry } from "./lib/src/types";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "./lib/src/types";
|
||||
import { type PluginDataEntry, PERIODIC_PLUGIN_SWEEP, type PluginList, type DevicePluginList, PSCHeader, PSCHeaderEnd } from "./types";
|
||||
import { createTextBlob, getDocData, isDocContentSame } from "./lib/src/utils";
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import { PouchDB } from "./lib/src/pouchdb-browser.js";
|
||||
import { isPluginMetadata, PeriodicProcessor } from "./utils";
|
||||
import { PluginDialogModal } from "./dialogs";
|
||||
import { NewNotice } from "./lib/src/wrapper";
|
||||
import { versionNumberString2Number } from "./lib/src/strbin";
|
||||
import { serialized, skipIfDuplicated } from "./lib/src/lock";
|
||||
import { LiveSyncCommands } from "./LiveSyncCommands";
|
||||
|
||||
export class PluginAndTheirSettings extends LiveSyncCommands {
|
||||
|
||||
get deviceAndVaultName() {
|
||||
return this.plugin.deviceAndVaultName;
|
||||
}
|
||||
pluginDialog: PluginDialogModal = null;
|
||||
periodicPluginSweepProcessor = new PeriodicProcessor(this.plugin, async () => await this.sweepPlugin(false));
|
||||
|
||||
showPluginSyncModal() {
|
||||
if (this.pluginDialog != null) {
|
||||
this.pluginDialog.open();
|
||||
} else {
|
||||
this.pluginDialog = new PluginDialogModal(this.app, this.plugin);
|
||||
this.pluginDialog.open();
|
||||
}
|
||||
}
|
||||
|
||||
hidePluginSyncModal() {
|
||||
if (this.pluginDialog != null) {
|
||||
this.pluginDialog.close();
|
||||
this.pluginDialog = null;
|
||||
}
|
||||
}
|
||||
onload(): void | Promise<void> {
|
||||
this.plugin.addCommand({
|
||||
id: "livesync-plugin-dialog",
|
||||
name: "Show Plugins and their settings",
|
||||
callback: () => {
|
||||
this.showPluginSyncModal();
|
||||
},
|
||||
});
|
||||
this.showPluginSyncModal();
|
||||
}
|
||||
onunload() {
|
||||
this.hidePluginSyncModal();
|
||||
this.periodicPluginSweepProcessor?.disable();
|
||||
}
|
||||
parseReplicationResultItem(doc: PouchDB.Core.ExistingDocument<EntryDoc>) {
|
||||
if (isPluginMetadata(doc._id)) {
|
||||
if (this.settings.notifyPluginOrSettingUpdated) {
|
||||
this.triggerCheckPluginUpdate();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async beforeReplicate(showMessage: boolean) {
|
||||
if (this.settings.autoSweepPlugins) {
|
||||
await this.sweepPlugin(showMessage);
|
||||
}
|
||||
}
|
||||
async onResume() {
|
||||
if (this.plugin.suspended)
|
||||
return;
|
||||
if (this.settings.autoSweepPlugins) {
|
||||
await this.sweepPlugin(false);
|
||||
}
|
||||
this.periodicPluginSweepProcessor.enable(this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges ? (PERIODIC_PLUGIN_SWEEP * 1000) : 0);
|
||||
}
|
||||
async onInitializeDatabase(showNotice: boolean) {
|
||||
if (this.settings.usePluginSync) {
|
||||
try {
|
||||
Logger("Scanning plugins...");
|
||||
await this.sweepPlugin(showNotice);
|
||||
Logger("Scanning plugins done");
|
||||
} catch (ex) {
|
||||
Logger("Scanning plugins failed");
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
async realizeSettingSyncMode() {
|
||||
this.periodicPluginSweepProcessor?.disable();
|
||||
if (this.plugin.suspended)
|
||||
return;
|
||||
if (this.settings.autoSweepPlugins) {
|
||||
await this.sweepPlugin(false);
|
||||
}
|
||||
this.periodicPluginSweepProcessor.enable(this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges ? (PERIODIC_PLUGIN_SWEEP * 1000) : 0);
|
||||
}
|
||||
|
||||
triggerCheckPluginUpdate() {
|
||||
(async () => await this.checkPluginUpdate())();
|
||||
}
|
||||
|
||||
|
||||
async getPluginList(): Promise<{ plugins: PluginList; allPlugins: DevicePluginList; thisDevicePlugins: DevicePluginList; }> {
|
||||
const docList = await this.localDatabase.allDocsRaw<PluginDataEntry>({ startkey: PSCHeader, endkey: PSCHeaderEnd, include_docs: false });
|
||||
const oldDocs: PluginDataEntry[] = ((await Promise.all(docList.rows.map(async (e) => await this.localDatabase.getDBEntry(e.id as FilePathWithPrefix /* WARN!! THIS SHOULD BE WRAPPED */)))).filter((e) => e !== false) as LoadedEntry[]).map((e) => JSON.parse(getDocData(e.data)));
|
||||
const plugins: { [key: string]: PluginDataEntry[]; } = {};
|
||||
const allPlugins: { [key: string]: PluginDataEntry; } = {};
|
||||
const thisDevicePlugins: { [key: string]: PluginDataEntry; } = {};
|
||||
for (const v of oldDocs) {
|
||||
if (typeof plugins[v.deviceVaultName] === "undefined") {
|
||||
plugins[v.deviceVaultName] = [];
|
||||
}
|
||||
plugins[v.deviceVaultName].push(v);
|
||||
allPlugins[v._id] = v;
|
||||
if (v.deviceVaultName == this.deviceAndVaultName) {
|
||||
thisDevicePlugins[v.manifest.id] = v;
|
||||
}
|
||||
}
|
||||
return { plugins, allPlugins, thisDevicePlugins };
|
||||
}
|
||||
|
||||
async checkPluginUpdate() {
|
||||
if (!this.plugin.settings.usePluginSync)
|
||||
return;
|
||||
await this.sweepPlugin(false);
|
||||
const { allPlugins, thisDevicePlugins } = await this.getPluginList();
|
||||
const arrPlugins = Object.values(allPlugins);
|
||||
let updateFound = false;
|
||||
for (const plugin of arrPlugins) {
|
||||
const ownPlugin = thisDevicePlugins[plugin.manifest.id];
|
||||
if (ownPlugin) {
|
||||
const remoteVersion = versionNumberString2Number(plugin.manifest.version);
|
||||
const ownVersion = versionNumberString2Number(ownPlugin.manifest.version);
|
||||
if (remoteVersion > ownVersion) {
|
||||
updateFound = true;
|
||||
}
|
||||
if (((plugin.mtime / 1000) | 0) > ((ownPlugin.mtime / 1000) | 0) && (plugin.dataJson ?? "") != (ownPlugin.dataJson ?? "")) {
|
||||
updateFound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updateFound) {
|
||||
const fragment = createFragment((doc) => {
|
||||
doc.createEl("a", null, (a) => {
|
||||
a.text = "There're some new plugins or their settings";
|
||||
a.addEventListener("click", () => this.showPluginSyncModal());
|
||||
});
|
||||
});
|
||||
NewNotice(fragment, 10000);
|
||||
} else {
|
||||
Logger("Everything is up to date.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
|
||||
async sweepPlugin(showMessage = false, specificPluginPath = "") {
|
||||
if (!this.settings.usePluginSync)
|
||||
return;
|
||||
if (!this.localDatabase.isReady)
|
||||
return;
|
||||
// @ts-ignore
|
||||
const pl = this.app.plugins;
|
||||
const manifests: PluginManifest[] = Object.values(pl.manifests);
|
||||
let specificPlugin = "";
|
||||
if (specificPluginPath != "") {
|
||||
specificPlugin = manifests.find(e => e.dir.endsWith("/" + specificPluginPath))?.id ?? "";
|
||||
}
|
||||
await skipIfDuplicated("sweepplugin", async () => {
|
||||
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
if (!this.deviceAndVaultName) {
|
||||
Logger("You have to set your device name.", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
Logger("Scanning plugins", logLevel);
|
||||
const oldDocs = await this.localDatabase.allDocsRaw<EntryDoc>({
|
||||
startkey: `ps:${this.deviceAndVaultName}-${specificPlugin}`,
|
||||
endkey: `ps:${this.deviceAndVaultName}-${specificPlugin}\u{10ffff}`,
|
||||
include_docs: true,
|
||||
});
|
||||
// Logger("OLD DOCS.", LOG_LEVEL_VERBOSE);
|
||||
// sweep current plugin.
|
||||
const procs = manifests.map(async (m) => {
|
||||
const pluginDataEntryID = `ps:${this.deviceAndVaultName}-${m.id}` as DocumentID;
|
||||
try {
|
||||
if (specificPlugin && m.id != specificPlugin) {
|
||||
return;
|
||||
}
|
||||
Logger(`Reading plugin:${m.name}(${m.id})`, LOG_LEVEL_VERBOSE);
|
||||
const path = normalizePath(m.dir) + "/";
|
||||
const files = ["manifest.json", "main.js", "styles.css", "data.json"];
|
||||
const pluginData: { [key: string]: string; } = {};
|
||||
for (const file of files) {
|
||||
const thePath = path + file;
|
||||
if (await this.plugin.vaultAccess.adapterExists(thePath)) {
|
||||
pluginData[file] = await this.plugin.vaultAccess.adapterRead(thePath);
|
||||
}
|
||||
}
|
||||
let mtime = 0;
|
||||
if (await this.plugin.vaultAccess.adapterExists(path + "/data.json")) {
|
||||
mtime = (await this.plugin.vaultAccess.adapterStat(path + "/data.json")).mtime;
|
||||
}
|
||||
|
||||
const p: PluginDataEntry = {
|
||||
_id: pluginDataEntryID,
|
||||
dataJson: pluginData["data.json"],
|
||||
deviceVaultName: this.deviceAndVaultName,
|
||||
mainJs: pluginData["main.js"],
|
||||
styleCss: pluginData["styles.css"],
|
||||
manifest: m,
|
||||
manifestJson: pluginData["manifest.json"],
|
||||
mtime: mtime,
|
||||
type: "plugin",
|
||||
};
|
||||
const blob = createTextBlob(JSON.stringify(p));
|
||||
const d: SavingEntry = {
|
||||
_id: p._id,
|
||||
path: p._id as string as FilePathWithPrefix,
|
||||
data: blob,
|
||||
ctime: mtime,
|
||||
mtime: mtime,
|
||||
size: blob.size,
|
||||
children: [],
|
||||
datatype: "plain",
|
||||
type: "plain"
|
||||
};
|
||||
Logger(`check diff:${m.name}(${m.id})`, LOG_LEVEL_VERBOSE);
|
||||
await serialized("plugin-" + m.id, async () => {
|
||||
const old = await this.localDatabase.getDBEntry(p._id as string as FilePathWithPrefix /* This also should be explained */, null, false, false);
|
||||
if (old !== false) {
|
||||
const oldData = { data: old.data, deleted: old._deleted };
|
||||
const newData = { data: d.data, deleted: d._deleted };
|
||||
if (await isDocContentSame(oldData.data, newData.data) && oldData.deleted == newData.deleted) {
|
||||
Logger(`Nothing changed:${m.name}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.localDatabase.putDBEntry(d);
|
||||
Logger(`Plugin saved:${m.name}`, logLevel);
|
||||
});
|
||||
} catch (ex) {
|
||||
Logger(`Plugin save failed:${m.name}`, LOG_LEVEL_NOTICE);
|
||||
} finally {
|
||||
oldDocs.rows = oldDocs.rows.filter((e) => e.id != pluginDataEntryID);
|
||||
}
|
||||
//remove saved plugin data.
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(procs);
|
||||
|
||||
const delDocs = oldDocs.rows.map((e) => {
|
||||
// e.doc._deleted = true;
|
||||
if (e.doc.type == "newnote" || e.doc.type == "plain") {
|
||||
e.doc.deleted = true;
|
||||
if (this.settings.deleteMetadataOfDeletedFiles) {
|
||||
e.doc._deleted = true;
|
||||
}
|
||||
} else {
|
||||
e.doc._deleted = true;
|
||||
}
|
||||
return e.doc;
|
||||
});
|
||||
Logger(`Deleting old plugin:(${delDocs.length})`, LOG_LEVEL_VERBOSE);
|
||||
await this.localDatabase.bulkDocsRaw(delDocs);
|
||||
Logger(`Scan plugin done.`, logLevel);
|
||||
});
|
||||
}
|
||||
|
||||
async applyPluginData(plugin: PluginDataEntry) {
|
||||
await serialized("plugin-" + plugin.manifest.id, async () => {
|
||||
const pluginTargetFolderPath = normalizePath(plugin.manifest.dir) + "/";
|
||||
// @ts-ignore
|
||||
const stat = this.app.plugins.enabledPlugins.has(plugin.manifest.id) == true;
|
||||
if (stat) {
|
||||
// @ts-ignore
|
||||
await this.app.plugins.unloadPlugin(plugin.manifest.id);
|
||||
Logger(`Unload plugin:${plugin.manifest.id}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
if (plugin.dataJson)
|
||||
await this.plugin.vaultAccess.adapterWrite(pluginTargetFolderPath + "data.json", plugin.dataJson);
|
||||
Logger("wrote:" + pluginTargetFolderPath + "data.json", LOG_LEVEL_NOTICE);
|
||||
if (stat) {
|
||||
// @ts-ignore
|
||||
await this.app.plugins.loadPlugin(plugin.manifest.id);
|
||||
Logger(`Load plugin:${plugin.manifest.id}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async applyPlugin(plugin: PluginDataEntry) {
|
||||
await serialized("plugin-" + plugin.manifest.id, async () => {
|
||||
// @ts-ignore
|
||||
const stat = this.app.plugins.enabledPlugins.has(plugin.manifest.id) == true;
|
||||
if (stat) {
|
||||
// @ts-ignore
|
||||
await this.app.plugins.unloadPlugin(plugin.manifest.id);
|
||||
Logger(`Unload plugin:${plugin.manifest.id}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
|
||||
const pluginTargetFolderPath = normalizePath(plugin.manifest.dir) + "/";
|
||||
if ((await this.plugin.vaultAccess.adapterExists(pluginTargetFolderPath)) === false) {
|
||||
await this.app.vault.adapter.mkdir(pluginTargetFolderPath);
|
||||
}
|
||||
await this.plugin.vaultAccess.adapterWrite(pluginTargetFolderPath + "main.js", plugin.mainJs);
|
||||
await this.plugin.vaultAccess.adapterWrite(pluginTargetFolderPath + "manifest.json", plugin.manifestJson);
|
||||
if (plugin.styleCss)
|
||||
await this.plugin.vaultAccess.adapterWrite(pluginTargetFolderPath + "styles.css", plugin.styleCss);
|
||||
if (stat) {
|
||||
// @ts-ignore
|
||||
await this.app.plugins.loadPlugin(plugin.manifest.id);
|
||||
Logger(`Load plugin:${plugin.manifest.id}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { App, Modal } from "./deps";
|
||||
import { type FilePath, type LoadedEntry } from "./lib/src/types";
|
||||
import JsonResolvePane from "./JsonResolvePane.svelte";
|
||||
import { waitForSignal } from "./lib/src/utils";
|
||||
|
||||
export class JsonResolveModal extends Modal {
|
||||
// result: Array<[number, string]>;
|
||||
filename: FilePath;
|
||||
callback: (keepRev: string, mergedStr?: string) => Promise<void>;
|
||||
docs: LoadedEntry[];
|
||||
component: JsonResolvePane;
|
||||
nameA: string;
|
||||
nameB: string;
|
||||
defaultSelect: string;
|
||||
|
||||
constructor(app: App, filename: FilePath, docs: LoadedEntry[], callback: (keepRev: string, mergedStr?: string) => Promise<void>, nameA?: string, nameB?: string, defaultSelect?: string) {
|
||||
super(app);
|
||||
this.callback = callback;
|
||||
this.filename = filename;
|
||||
this.docs = docs;
|
||||
this.nameA = nameA;
|
||||
this.nameB = nameB;
|
||||
this.defaultSelect = defaultSelect;
|
||||
waitForSignal(`cancel-internal-conflict:${filename}`).then(() => this.close());
|
||||
}
|
||||
async UICallback(keepRev: string, mergedStr?: string) {
|
||||
this.close();
|
||||
await this.callback(keepRev, mergedStr);
|
||||
this.callback = null;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText("Conflicted Setting");
|
||||
contentEl.empty();
|
||||
|
||||
if (this.component == null) {
|
||||
this.component = new JsonResolvePane({
|
||||
target: contentEl,
|
||||
props: {
|
||||
docs: this.docs,
|
||||
filename: this.filename,
|
||||
nameA: this.nameA,
|
||||
nameB: this.nameB,
|
||||
defaultSelect: this.defaultSelect,
|
||||
callback: (keepRev: string, mergedStr: string) => this.UICallback(keepRev, mergedStr),
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
// contentEl.empty();
|
||||
if (this.callback != null) {
|
||||
this.callback(null);
|
||||
}
|
||||
if (this.component != null) {
|
||||
this.component.$destroy();
|
||||
this.component = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { App, Modal } from "./deps";
|
||||
import type { ReactiveInstance, } from "./lib/src/reactive";
|
||||
import { logMessages } from "./lib/src/stores";
|
||||
import { escapeStringToHTML } from "./lib/src/strbin";
|
||||
import ObsidianLiveSyncPlugin from "./main";
|
||||
|
||||
export class LogDisplayModal extends Modal {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
logEl: HTMLDivElement;
|
||||
unsubscribe: () => void;
|
||||
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText("Sync status");
|
||||
|
||||
contentEl.empty();
|
||||
const div = contentEl.createDiv("");
|
||||
div.addClass("op-scrollable");
|
||||
div.addClass("op-pre");
|
||||
this.logEl = div;
|
||||
function updateLog(logs: ReactiveInstance<string[]>) {
|
||||
const e = logs.value;
|
||||
let msg = "";
|
||||
for (const v of e) {
|
||||
msg += escapeStringToHTML(v) + "<br>";
|
||||
}
|
||||
this.logEl.innerHTML = msg;
|
||||
}
|
||||
logMessages.onChanged(updateLog);
|
||||
this.unsubscribe = () => logMessages.offChanged(updateLog);
|
||||
}
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.unsubscribe) this.unsubscribe();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
||||
import type { SerializedFileAccess } from "./SerializedFileAccess";
|
||||
import { Plugin, TAbstractFile, TFile, TFolder } from "./deps";
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import { isPlainText, shouldBeIgnored } from "./lib/src/path";
|
||||
import type { KeyedQueueProcessor } from "./lib/src/processor";
|
||||
import { LOG_LEVEL_NOTICE, type FilePath, type ObsidianLiveSyncSettings } from "./lib/src/types";
|
||||
import { delay } from "./lib/src/utils";
|
||||
import { type FileEventItem, type FileEventType, type FileInfo, type InternalFileInfo } from "./types";
|
||||
|
||||
|
||||
export abstract class StorageEventManager {
|
||||
abstract beginWatch(): void;
|
||||
}
|
||||
|
||||
type LiveSyncForStorageEventManager = Plugin &
|
||||
{
|
||||
settings: ObsidianLiveSyncSettings
|
||||
ignoreFiles: string[],
|
||||
vaultAccess: SerializedFileAccess
|
||||
} & {
|
||||
isTargetFile: (file: string | TAbstractFile) => Promise<boolean>,
|
||||
fileEventQueue: KeyedQueueProcessor<FileEventItem, any>,
|
||||
isFileSizeExceeded: (size: number) => boolean;
|
||||
};
|
||||
|
||||
|
||||
export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
plugin: LiveSyncForStorageEventManager;
|
||||
constructor(plugin: LiveSyncForStorageEventManager) {
|
||||
super();
|
||||
this.plugin = plugin;
|
||||
}
|
||||
beginWatch() {
|
||||
const plugin = this.plugin;
|
||||
this.watchVaultChange = this.watchVaultChange.bind(this);
|
||||
this.watchVaultCreate = this.watchVaultCreate.bind(this);
|
||||
this.watchVaultDelete = this.watchVaultDelete.bind(this);
|
||||
this.watchVaultRename = this.watchVaultRename.bind(this);
|
||||
this.watchVaultRawEvents = this.watchVaultRawEvents.bind(this);
|
||||
plugin.registerEvent(plugin.app.vault.on("modify", this.watchVaultChange));
|
||||
plugin.registerEvent(plugin.app.vault.on("delete", this.watchVaultDelete));
|
||||
plugin.registerEvent(plugin.app.vault.on("rename", this.watchVaultRename));
|
||||
plugin.registerEvent(plugin.app.vault.on("create", this.watchVaultCreate));
|
||||
//@ts-ignore : Internal API
|
||||
plugin.registerEvent(plugin.app.vault.on("raw", this.watchVaultRawEvents));
|
||||
plugin.fileEventQueue.startPipeline();
|
||||
}
|
||||
|
||||
watchVaultCreate(file: TAbstractFile, ctx?: any) {
|
||||
this.appendWatchEvent([{ type: "CREATE", file }], ctx);
|
||||
}
|
||||
|
||||
watchVaultChange(file: TAbstractFile, ctx?: any) {
|
||||
this.appendWatchEvent([{ type: "CHANGED", file }], ctx);
|
||||
}
|
||||
|
||||
watchVaultDelete(file: TAbstractFile, ctx?: any) {
|
||||
this.appendWatchEvent([{ type: "DELETE", file }], ctx);
|
||||
}
|
||||
watchVaultRename(file: TAbstractFile, oldFile: string, ctx?: any) {
|
||||
if (file instanceof TFile) {
|
||||
this.appendWatchEvent([
|
||||
{ type: "DELETE", file: { path: oldFile as FilePath, mtime: file.stat.mtime, ctime: file.stat.ctime, size: file.stat.size, deleted: true } },
|
||||
{ type: "CREATE", file },
|
||||
], ctx);
|
||||
}
|
||||
}
|
||||
// Watch raw events (Internal API)
|
||||
watchVaultRawEvents(path: FilePath) {
|
||||
if (this.plugin.settings.useIgnoreFiles && this.plugin.ignoreFiles.some(e => path.endsWith(e.trim()))) {
|
||||
// If it is one of ignore files, refresh the cached one.
|
||||
this.plugin.isTargetFile(path).then(() => this._watchVaultRawEvents(path));
|
||||
} else {
|
||||
this._watchVaultRawEvents(path);
|
||||
}
|
||||
}
|
||||
|
||||
_watchVaultRawEvents(path: FilePath) {
|
||||
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;
|
||||
this.appendWatchEvent(
|
||||
[{
|
||||
type: "INTERNAL",
|
||||
file: { path, mtime: 0, ctime: 0, size: 0 }
|
||||
}], null);
|
||||
}
|
||||
// Cache file and waiting to can be proceed.
|
||||
async appendWatchEvent(params: { type: FileEventType, file: TAbstractFile | InternalFileInfo, oldPath?: string }[], ctx?: any) {
|
||||
if (!this.plugin.settings.isConfigured) return;
|
||||
if (this.plugin.settings.suspendFileWatching) return;
|
||||
for (const param of params) {
|
||||
if (shouldBeIgnored(param.file.path)) {
|
||||
continue;
|
||||
}
|
||||
const atomicKey = [0, 0, 0, 0, 0, 0].map(e => `${Math.floor(Math.random() * 100000)}`).join("-");
|
||||
const type = param.type;
|
||||
const file = param.file;
|
||||
const oldPath = param.oldPath;
|
||||
const size = file instanceof TFile ? file.stat.size : (file as InternalFileInfo)?.size ?? 0;
|
||||
if (this.plugin.isFileSizeExceeded(size) && (type == "CREATE" || type == "CHANGED")) {
|
||||
Logger(`The storage file has been changed but exceeds the maximum size. Skipping: ${param.file.path}`, LOG_LEVEL_NOTICE);
|
||||
continue;
|
||||
}
|
||||
if (file instanceof TFolder) continue;
|
||||
if (!await this.plugin.isTargetFile(file.path)) continue;
|
||||
|
||||
let cache: null | string | ArrayBuffer;
|
||||
// new file or something changed, cache the changes.
|
||||
if (file instanceof TFile && (type == "CREATE" || type == "CHANGED")) {
|
||||
// Wait for a bit while to let the writer has marked `touched` at the file.
|
||||
await delay(10);
|
||||
if (this.plugin.vaultAccess.recentlyTouched(file)) {
|
||||
continue;
|
||||
}
|
||||
if (!isPlainText(file.name)) {
|
||||
cache = await this.plugin.vaultAccess.vaultReadBinary(file);
|
||||
} else {
|
||||
cache = await this.plugin.vaultAccess.vaultCacheRead(file);
|
||||
if (!cache) cache = await this.plugin.vaultAccess.vaultRead(file);
|
||||
}
|
||||
}
|
||||
const fileInfo = file instanceof TFile ? {
|
||||
ctime: file.stat.ctime,
|
||||
mtime: file.stat.mtime,
|
||||
file: file,
|
||||
path: file.path,
|
||||
size: file.stat.size
|
||||
} as FileInfo : file as InternalFileInfo;
|
||||
|
||||
this.plugin.fileEventQueue.enqueueWithKey(`file-${fileInfo.path}`, {
|
||||
type,
|
||||
args: {
|
||||
file: fileInfo,
|
||||
oldPath,
|
||||
cache,
|
||||
ctx
|
||||
},
|
||||
key: atomicKey
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { deleteDB, type IDBPDatabase, openDB } from "idb";
|
||||
export interface KeyValueDatabase {
|
||||
get<T>(key: string): Promise<T>;
|
||||
set<T>(key: string, value: T): Promise<IDBValidKey>;
|
||||
del(key: string): Promise<void>;
|
||||
get<T>(key: IDBValidKey): Promise<T>;
|
||||
set<T>(key: IDBValidKey, value: T): Promise<IDBValidKey>;
|
||||
del(key: IDBValidKey): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise<IDBValidKey[]>;
|
||||
close(): void;
|
||||
destroy(): void;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
const databaseCache: { [key: string]: IDBPDatabase<any> } = {};
|
||||
export const OpenKeyValueDatabase = async (dbKey: string): Promise<KeyValueDatabase> => {
|
||||
@@ -20,24 +20,23 @@ export const OpenKeyValueDatabase = async (dbKey: string): Promise<KeyValueDatab
|
||||
db.createObjectStore(storeKey);
|
||||
},
|
||||
});
|
||||
let db: IDBPDatabase<any> = null;
|
||||
db = await dbPromise;
|
||||
const db = await dbPromise;
|
||||
databaseCache[dbKey] = db;
|
||||
return {
|
||||
get<T>(key: string): Promise<T> {
|
||||
return db.get(storeKey, key);
|
||||
async get<T>(key: IDBValidKey): Promise<T> {
|
||||
return await db.get(storeKey, key);
|
||||
},
|
||||
set<T>(key: string, value: T) {
|
||||
return db.put(storeKey, value, key);
|
||||
async set<T>(key: IDBValidKey, value: T) {
|
||||
return await db.put(storeKey, value, key);
|
||||
},
|
||||
del(key: string) {
|
||||
return db.delete(storeKey, key);
|
||||
async del(key: IDBValidKey) {
|
||||
return await db.delete(storeKey, key);
|
||||
},
|
||||
clear() {
|
||||
return db.clear(storeKey);
|
||||
async clear() {
|
||||
return await db.clear(storeKey);
|
||||
},
|
||||
keys(query?: IDBValidKey | IDBKeyRange, count?: number) {
|
||||
return db.getAllKeys(storeKey, query, count);
|
||||
async keys(query?: IDBValidKey | IDBKeyRange, count?: number) {
|
||||
return await db.getAllKeys(storeKey, query, count);
|
||||
},
|
||||
close() {
|
||||
delete databaseCache[dbKey];
|
||||
134
src/common/ObsHttpHandler.ts
Normal file
134
src/common/ObsHttpHandler.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// This file is based on a file that was published by the @remotely-save, under the Apache 2 License.
|
||||
// I would love to express my deepest gratitude to the original authors for their hard work and dedication. Without their contributions, this project would not have been possible.
|
||||
//
|
||||
// Original Implementation is here: https://github.com/remotely-save/remotely-save/blob/28b99557a864ef59c19d2ad96101196e401718f0/src/remoteForS3.ts
|
||||
|
||||
import {
|
||||
FetchHttpHandler,
|
||||
type FetchHttpHandlerOptions,
|
||||
} from "@smithy/fetch-http-handler";
|
||||
import { HttpRequest, HttpResponse, type HttpHandlerOptions } from "@smithy/protocol-http";
|
||||
//@ts-ignore
|
||||
import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout";
|
||||
import { buildQueryString } from "@smithy/querystring-builder";
|
||||
import { requestUrl, type RequestUrlParam } from "../deps.ts";
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// special handler using Obsidian requestUrl
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* This is close to origin implementation of FetchHttpHandler
|
||||
* https://github.com/aws/aws-sdk-js-v3/blob/main/packages/fetch-http-handler/src/fetch-http-handler.ts
|
||||
* that is released under Apache 2 License.
|
||||
* But this uses Obsidian requestUrl instead.
|
||||
*/
|
||||
export class ObsHttpHandler extends FetchHttpHandler {
|
||||
requestTimeoutInMs: number | undefined;
|
||||
reverseProxyNoSignUrl: string | undefined;
|
||||
constructor(
|
||||
options?: FetchHttpHandlerOptions,
|
||||
reverseProxyNoSignUrl?: string
|
||||
) {
|
||||
super(options);
|
||||
this.requestTimeoutInMs =
|
||||
options === undefined ? undefined : options.requestTimeout;
|
||||
this.reverseProxyNoSignUrl = reverseProxyNoSignUrl;
|
||||
}
|
||||
// eslint-disable-next-line require-await
|
||||
async handle(
|
||||
request: HttpRequest,
|
||||
{ abortSignal }: HttpHandlerOptions = {}
|
||||
): Promise<{ response: HttpResponse }> {
|
||||
if (abortSignal?.aborted) {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
return Promise.reject(abortError);
|
||||
}
|
||||
|
||||
let path = request.path;
|
||||
if (request.query) {
|
||||
const queryString = buildQueryString(request.query);
|
||||
if (queryString) {
|
||||
path += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const { port, method } = request;
|
||||
let url = `${request.protocol}//${request.hostname}${port ? `:${port}` : ""
|
||||
}${path}`;
|
||||
if (
|
||||
this.reverseProxyNoSignUrl !== undefined &&
|
||||
this.reverseProxyNoSignUrl !== ""
|
||||
) {
|
||||
const urlObj = new URL(url);
|
||||
urlObj.host = this.reverseProxyNoSignUrl;
|
||||
url = urlObj.href;
|
||||
}
|
||||
const body =
|
||||
method === "GET" || method === "HEAD" ? undefined : request.body;
|
||||
|
||||
const transformedHeaders: Record<string, string> = {};
|
||||
for (const key of Object.keys(request.headers)) {
|
||||
const keyLower = key.toLowerCase();
|
||||
if (keyLower === "host" || keyLower === "content-length") {
|
||||
continue;
|
||||
}
|
||||
transformedHeaders[keyLower] = request.headers[key];
|
||||
}
|
||||
|
||||
let contentType: string | undefined = undefined;
|
||||
if (transformedHeaders["content-type"] !== undefined) {
|
||||
contentType = transformedHeaders["content-type"];
|
||||
}
|
||||
|
||||
let transformedBody: any = body;
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
transformedBody = new Uint8Array(body.buffer).buffer;
|
||||
}
|
||||
|
||||
const param: RequestUrlParam = {
|
||||
body: transformedBody,
|
||||
headers: transformedHeaders,
|
||||
method: method,
|
||||
url: url,
|
||||
contentType: contentType,
|
||||
};
|
||||
|
||||
const raceOfPromises = [
|
||||
requestUrl(param).then((rsp) => {
|
||||
const headers = rsp.headers;
|
||||
const headersLower: Record<string, string> = {};
|
||||
for (const key of Object.keys(headers)) {
|
||||
headersLower[key.toLowerCase()] = headers[key];
|
||||
}
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(rsp.arrayBuffer));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new HttpResponse({
|
||||
headers: headersLower,
|
||||
statusCode: rsp.status,
|
||||
body: stream,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
requestTimeout(this.requestTimeoutInMs),
|
||||
];
|
||||
|
||||
if (abortSignal) {
|
||||
raceOfPromises.push(
|
||||
new Promise<never>((resolve, reject) => {
|
||||
abortSignal.onabort = () => {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
reject(abortError);
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.race(raceOfPromises);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { ButtonComponent } from "obsidian";
|
||||
import { App, FuzzySuggestModal, MarkdownRenderer, Modal, Plugin, Setting } from "./deps";
|
||||
import ObsidianLiveSyncPlugin from "./main";
|
||||
import { App, FuzzySuggestModal, MarkdownRenderer, Modal, Plugin, Setting } from "../deps.ts";
|
||||
import ObsidianLiveSyncPlugin from "../main.ts";
|
||||
|
||||
//@ts-ignore
|
||||
import PluginPane from "./PluginPane.svelte";
|
||||
import PluginPane from "../ui/PluginPane.svelte";
|
||||
|
||||
export class PluginDialogModal extends Modal {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
logEl: HTMLDivElement;
|
||||
component: PluginPane = null;
|
||||
component: PluginPane | undefined;
|
||||
isOpened() {
|
||||
return this.component != null;
|
||||
return this.component != undefined;
|
||||
}
|
||||
|
||||
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
|
||||
@@ -20,8 +19,11 @@ export class PluginDialogModal extends Modal {
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText("Customization Sync (Beta2)")
|
||||
if (this.component == null) {
|
||||
this.contentEl.style.overflow = "auto";
|
||||
this.contentEl.style.display = "flex";
|
||||
this.contentEl.style.flexDirection = "column";
|
||||
this.titleEl.setText("Customization Sync (Beta3)")
|
||||
if (!this.component) {
|
||||
this.component = new PluginPane({
|
||||
target: contentEl,
|
||||
props: { plugin: this.plugin },
|
||||
@@ -30,9 +32,9 @@ export class PluginDialogModal extends Modal {
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.component != null) {
|
||||
if (this.component) {
|
||||
this.component.$destroy();
|
||||
this.component = null;
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,13 +96,13 @@ export class InputStringDialog extends Modal {
|
||||
}
|
||||
export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
app: App;
|
||||
callback: (e: string) => void = () => { };
|
||||
callback: ((e: string) => void) | undefined = () => { };
|
||||
getItemsFun: () => string[] = () => {
|
||||
return ["yes", "no"];
|
||||
|
||||
}
|
||||
|
||||
constructor(app: App, note: string, placeholder: string | null, getItemsFun: () => string[], callback: (e: string) => void) {
|
||||
constructor(app: App, note: string, placeholder: string | undefined, getItemsFun: (() => string[]) | undefined, callback: (e: string) => void) {
|
||||
super(app);
|
||||
this.app = app;
|
||||
this.setPlaceholder((placeholder ?? "y/n) ") + note);
|
||||
@@ -118,13 +120,14 @@ export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
|
||||
onChooseItem(item: string, evt: MouseEvent | KeyboardEvent): void {
|
||||
// debugger;
|
||||
this.callback(item);
|
||||
this.callback = null;
|
||||
this.callback?.(item);
|
||||
this.callback = undefined;
|
||||
}
|
||||
onClose(): void {
|
||||
setTimeout(() => {
|
||||
if (this.callback != null) {
|
||||
if (this.callback) {
|
||||
this.callback("");
|
||||
this.callback = undefined;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
@@ -136,16 +139,16 @@ export class MessageBox extends Modal {
|
||||
title: string;
|
||||
contentMd: string;
|
||||
buttons: string[];
|
||||
result: string;
|
||||
result: string | false = false;
|
||||
isManuallyClosed = false;
|
||||
defaultAction: string | undefined;
|
||||
timeout: number | undefined;
|
||||
timer: ReturnType<typeof setInterval> = undefined;
|
||||
timer: ReturnType<typeof setInterval> | undefined = undefined;
|
||||
defaultButtonComponent: ButtonComponent | undefined;
|
||||
|
||||
onSubmit: (result: string | false) => void;
|
||||
|
||||
constructor(plugin: Plugin, title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout: number, onSubmit: (result: (typeof buttons)[number] | false) => void) {
|
||||
constructor(plugin: Plugin, title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout: number | undefined, onSubmit: (result: (typeof buttons)[number] | false) => void) {
|
||||
super(plugin.app);
|
||||
this.plugin = plugin;
|
||||
this.title = title;
|
||||
@@ -156,6 +159,7 @@ export class MessageBox extends Modal {
|
||||
this.timeout = timeout;
|
||||
if (this.timeout) {
|
||||
this.timer = setInterval(() => {
|
||||
if (this.timeout === undefined) return;
|
||||
this.timeout--;
|
||||
if (this.timeout < 0) {
|
||||
if (this.timer) {
|
||||
@@ -166,7 +170,7 @@ export class MessageBox extends Modal {
|
||||
this.isManuallyClosed = true;
|
||||
this.close();
|
||||
} else {
|
||||
this.defaultButtonComponent.setButtonText(`( ${this.timeout} ) ${defaultAction}`);
|
||||
this.defaultButtonComponent?.setButtonText(`( ${this.timeout} ) ${defaultAction}`);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -223,7 +227,7 @@ export class MessageBox extends Modal {
|
||||
}
|
||||
|
||||
|
||||
export function confirmWithMessage(plugin: Plugin, title: string, contentMd: string, buttons: string[], defaultAction?: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false> {
|
||||
export function confirmWithMessage(plugin: Plugin, title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false> {
|
||||
return new Promise((res) => {
|
||||
const dialog = new MessageBox(plugin, title, contentMd, buttons, defaultAction, timeout, (result) => res(result));
|
||||
dialog.open();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PersistentMap } from "./lib/src/PersistentMap";
|
||||
import { PersistentMap } from "../lib/src/dataobject/PersistentMap.ts";
|
||||
|
||||
export let sameChangePairs: PersistentMap<number[]>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type PluginManifest, TFile } from "./deps";
|
||||
import { type DatabaseEntry, type EntryBody, type FilePath } from "./lib/src/types";
|
||||
import { type PluginManifest, TFile } from "../deps.ts";
|
||||
import { type DatabaseEntry, type EntryBody, type FilePath } from "../lib/src/common/types.ts";
|
||||
|
||||
export interface PluginDataEntry extends DatabaseEntry {
|
||||
deviceVaultName: string;
|
||||
@@ -60,6 +60,9 @@ export type FileEventItem = {
|
||||
type: FileEventType,
|
||||
args: FileEventArgs,
|
||||
key: string,
|
||||
skipBatchWait?: boolean,
|
||||
cancelled?: boolean,
|
||||
batched?: boolean
|
||||
}
|
||||
|
||||
// Hidden items (Now means `chunk`)
|
||||
@@ -1,16 +1,16 @@
|
||||
import { normalizePath, Platform, TAbstractFile, App, type RequestUrlParam, requestUrl, TFile } from "./deps";
|
||||
import { path2id_base, id2path_base, isValidFilenameInLinux, isValidFilenameInDarwin, isValidFilenameInWidows, isValidFilenameInAndroid, stripAllPrefixes } from "./lib/src/path";
|
||||
import { normalizePath, Platform, TAbstractFile, App, type RequestUrlParam, requestUrl, TFile } from "../deps.ts";
|
||||
import { path2id_base, id2path_base, isValidFilenameInLinux, isValidFilenameInDarwin, isValidFilenameInWidows, isValidFilenameInAndroid, stripAllPrefixes } from "../lib/src/string_and_binary/path.ts";
|
||||
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import { LOG_LEVEL_VERBOSE, type AnyEntry, type DocumentID, type EntryHasPath, type FilePath, type FilePathWithPrefix } from "./lib/src/types";
|
||||
import { CHeader, ICHeader, ICHeaderLength, ICXHeader, PSCHeader } from "./types";
|
||||
import { InputStringDialog, PopoverSelectString } from "./dialogs";
|
||||
import type ObsidianLiveSyncPlugin from "./main";
|
||||
import { writeString } from "./lib/src/strbin";
|
||||
import { fireAndForget } from "./lib/src/utils";
|
||||
import { sameChangePairs } from "./stores";
|
||||
import { Logger } from "../lib/src/common/logger.ts";
|
||||
import { LOG_LEVEL_VERBOSE, type AnyEntry, type DocumentID, type EntryHasPath, type FilePath, type FilePathWithPrefix } from "../lib/src/common/types.ts";
|
||||
import { CHeader, ICHeader, ICHeaderLength, ICXHeader, PSCHeader } from "./types.ts";
|
||||
import { InputStringDialog, PopoverSelectString } from "./dialogs.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
import { writeString } from "../lib/src/string_and_binary/convert.ts";
|
||||
import { fireAndForget } from "../lib/src/common/utils.ts";
|
||||
import { sameChangePairs } from "./stores.ts";
|
||||
|
||||
export { scheduleTask, setPeriodicTask, cancelTask, cancelAllTasks, cancelPeriodicTask, cancelAllPeriodicTask, } from "./lib/src/task";
|
||||
export { scheduleTask, setPeriodicTask, cancelTask, cancelAllTasks, cancelPeriodicTask, cancelAllPeriodicTask, } from "../lib/src/concurrency/task.ts";
|
||||
|
||||
// For backward compatibility, using the path for determining id.
|
||||
// Only CouchDB unacceptable ID (that starts with an underscore) has been prefixed with "/".
|
||||
@@ -242,14 +242,11 @@ export function mergeObject(
|
||||
ret[key] = v;
|
||||
}
|
||||
}
|
||||
const retSorted = Object.fromEntries(Object.entries(ret).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
if (Array.isArray(objA) && Array.isArray(objB)) {
|
||||
return Object.values(Object.entries(ret)
|
||||
.sort()
|
||||
.reduce((p, [key, value]) => ({ ...p, [key]: value }), {}));
|
||||
return Object.values(retSorted);
|
||||
}
|
||||
return Object.entries(ret)
|
||||
.sort()
|
||||
.reduce((p, [key, value]) => ({ ...p, [key]: value }), {});
|
||||
return retSorted;
|
||||
}
|
||||
|
||||
export function flattenObject(obj: Record<string | number | symbol, any>, path: string[] = []): [string, any][] {
|
||||
@@ -313,7 +310,7 @@ export function isCustomisationSyncMetadata(str: string): boolean {
|
||||
|
||||
export const askYesNo = (app: App, message: string): Promise<"yes" | "no"> => {
|
||||
return new Promise((res) => {
|
||||
const popover = new PopoverSelectString(app, message, null, null, (result) => res(result as "yes" | "no"));
|
||||
const popover = new PopoverSelectString(app, message, undefined, undefined, (result) => res(result as "yes" | "no"));
|
||||
popover.open();
|
||||
});
|
||||
};
|
||||
@@ -327,7 +324,7 @@ export const askSelectString = (app: App, message: string, items: string[]): Pro
|
||||
};
|
||||
|
||||
|
||||
export const askString = (app: App, title: string, key: string, placeholder: string, isPassword?: boolean): Promise<string | false> => {
|
||||
export const askString = (app: App, title: string, key: string, placeholder: string, isPassword: boolean = false): Promise<string | false> => {
|
||||
return new Promise((res) => {
|
||||
const dialog = new InputStringDialog(app, title, key, placeholder, isPassword, (result) => res(result));
|
||||
dialog.open();
|
||||
@@ -400,7 +397,7 @@ export const _requestToCouchDB = async (baseUri: string, username: string, passw
|
||||
};
|
||||
return await requestUrl(requestParam);
|
||||
}
|
||||
export const requestToCouchDB = async (baseUri: string, username: string, password: string, origin: string, key?: string, body?: string, method?: string) => {
|
||||
export const requestToCouchDB = async (baseUri: string, username: string, password: string, origin: string = "", key?: string, body?: string, method?: string) => {
|
||||
const uri = `_node/_local/_config${key ? "/" + key : ""}`;
|
||||
return await _requestToCouchDB(baseUri, username, password, origin, uri, body, method);
|
||||
};
|
||||
@@ -440,7 +437,7 @@ export function compareMTime(baseMTime: number, targetMTime: number): typeof BAS
|
||||
export function markChangesAreSame(file: TFile | AnyEntry | string, mtime1: number, mtime2: number) {
|
||||
if (mtime1 === mtime2) return true;
|
||||
const key = typeof file == "string" ? file : file instanceof TFile ? file.path : file.path ?? file._id;
|
||||
const pairs = sameChangePairs.get(key, []);
|
||||
const pairs = sameChangePairs.get(key, []) || [];
|
||||
if (pairs.some(e => e == mtime1 || e == mtime2)) {
|
||||
sameChangePairs.set(key, [...new Set([...pairs, mtime1, mtime2])]);
|
||||
} else {
|
||||
@@ -449,12 +446,16 @@ export function markChangesAreSame(file: TFile | AnyEntry | string, mtime1: numb
|
||||
}
|
||||
export function isMarkedAsSameChanges(file: TFile | AnyEntry | string, mtimes: number[]) {
|
||||
const key = typeof file == "string" ? file : file instanceof TFile ? file.path : file.path ?? file._id;
|
||||
const pairs = sameChangePairs.get(key, []);
|
||||
const pairs = sameChangePairs.get(key, []) || [];
|
||||
if (mtimes.every(e => pairs.indexOf(e) !== -1)) {
|
||||
return EVEN;
|
||||
}
|
||||
}
|
||||
export function compareFileFreshness(baseFile: TFile | AnyEntry, checkTarget: TFile | AnyEntry): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
export function compareFileFreshness(baseFile: TFile | AnyEntry | undefined, checkTarget: TFile | AnyEntry | undefined): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
if (baseFile === undefined && checkTarget == undefined) return EVEN;
|
||||
if (baseFile == undefined) return TARGET_IS_NEW;
|
||||
if (checkTarget == undefined) return BASE_IS_NEW;
|
||||
|
||||
const modifiedBase = baseFile instanceof TFile ? baseFile?.stat?.mtime ?? 0 : baseFile?.mtime ?? 0;
|
||||
const modifiedTarget = checkTarget instanceof TFile ? checkTarget?.stat?.mtime ?? 0 : checkTarget?.mtime ?? 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FilePath } from "./lib/src/types";
|
||||
import { type FilePath } from "./lib/src/common/types.ts";
|
||||
|
||||
export {
|
||||
addIcon, App, debounce, Editor, FuzzySuggestModal, MarkdownRenderer, MarkdownView, Modal, Notice, Platform, Plugin, PluginSettingTab, requestUrl, sanitizeHTMLToDom, Setting, stringifyYaml, TAbstractFile, TextAreaComponent, TFile, TFolder,
|
||||
|
||||
1476
src/features/CmdConfigSync.ts
Normal file
1476
src/features/CmdConfigSync.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,20 @@
|
||||
import { normalizePath, type PluginManifest, type ListedFiles } from "./deps";
|
||||
import { type EntryDoc, type LoadedEntry, type InternalFileEntry, type FilePathWithPrefix, type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, MODE_SELECTIVE, MODE_PAUSED, type SavingEntry, type DocumentID } from "./lib/src/types";
|
||||
import { type InternalFileInfo, ICHeader, ICHeaderEnd } from "./types";
|
||||
import { createBinaryBlob, isDocContentSame, sendSignal } from "./lib/src/utils";
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import { PouchDB } from "./lib/src/pouchdb-browser.js";
|
||||
import { isInternalMetadata, PeriodicProcessor } from "./utils";
|
||||
import { WrappedNotice } from "./lib/src/wrapper";
|
||||
import { decodeBinary, encodeBinary } from "./lib/src/strbin";
|
||||
import { serialized } from "./lib/src/lock";
|
||||
import { JsonResolveModal } from "./JsonResolveModal";
|
||||
import { LiveSyncCommands } from "./LiveSyncCommands";
|
||||
import { addPrefix, stripAllPrefixes } from "./lib/src/path";
|
||||
import { KeyedQueueProcessor, QueueProcessor } from "./lib/src/processor";
|
||||
import { hiddenFilesEventCount, hiddenFilesProcessingCount } from "./lib/src/stores";
|
||||
import { normalizePath, type PluginManifest, type ListedFiles } from "../deps.ts";
|
||||
import { type EntryDoc, type LoadedEntry, type InternalFileEntry, type FilePathWithPrefix, type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, MODE_SELECTIVE, MODE_PAUSED, type SavingEntry, type DocumentID } from "../lib/src/common/types.ts";
|
||||
import { type InternalFileInfo, ICHeader, ICHeaderEnd } from "../common/types.ts";
|
||||
import { readAsBlob, isDocContentSame, sendSignal, readContent, createBlob } from "../lib/src/common/utils.ts";
|
||||
import { Logger } from "../lib/src/common/logger.ts";
|
||||
import { PouchDB } from "../lib/src/pouchdb/pouchdb-browser.js";
|
||||
import { isInternalMetadata, PeriodicProcessor } from "../common/utils.ts";
|
||||
import { serialized } from "../lib/src/concurrency/lock.ts";
|
||||
import { JsonResolveModal } from "../ui/JsonResolveModal.ts";
|
||||
import { LiveSyncCommands } from "./LiveSyncCommands.ts";
|
||||
import { addPrefix, stripAllPrefixes } from "../lib/src/string_and_binary/path.ts";
|
||||
import { QueueProcessor } from "../lib/src/concurrency/processor.ts";
|
||||
import { hiddenFilesEventCount, hiddenFilesProcessingCount } from "../lib/src/mock_and_interop/stores.ts";
|
||||
|
||||
export class HiddenFileSync extends LiveSyncCommands {
|
||||
periodicInternalFileScanProcessor: PeriodicProcessor = new PeriodicProcessor(this.plugin, async () => this.settings.syncInternalFiles && this.localDatabase.isReady && await this.syncInternalFilesAndDatabase("push", false));
|
||||
confirmPopup: WrappedNotice = null;
|
||||
|
||||
get kvDB() {
|
||||
return this.plugin.kvDB;
|
||||
}
|
||||
@@ -67,23 +65,23 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
realizeSettingSyncMode(): Promise<void> {
|
||||
this.periodicInternalFileScanProcessor?.disable();
|
||||
if (this.plugin.suspended)
|
||||
return;
|
||||
return Promise.resolve();
|
||||
if (!this.plugin.isReady)
|
||||
return;
|
||||
return Promise.resolve();
|
||||
this.periodicInternalFileScanProcessor.enable(this.settings.syncInternalFiles && this.settings.syncInternalFilesInterval ? (this.settings.syncInternalFilesInterval * 1000) : 0);
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
procInternalFile(filename: string) {
|
||||
this.internalFileProcessor.enqueueWithKey(filename, filename);
|
||||
this.internalFileProcessor.enqueue(filename);
|
||||
}
|
||||
internalFileProcessor = new KeyedQueueProcessor<string, any>(
|
||||
internalFileProcessor = new QueueProcessor<string, any>(
|
||||
async (filenames) => {
|
||||
Logger(`START :Applying hidden ${filenames.length} files change`, LOG_LEVEL_VERBOSE);
|
||||
await this.syncInternalFilesAndDatabase("pull", false, false, filenames);
|
||||
Logger(`DONE :Applying hidden ${filenames.length} files change`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}, { batchSize: 100, concurrentLimit: 1, delay: 10, yieldThreshold: 10, suspended: false, totalRemainingReactiveSource: hiddenFilesEventCount }
|
||||
}, { batchSize: 100, concurrentLimit: 1, delay: 10, yieldThreshold: 100, suspended: false, totalRemainingReactiveSource: hiddenFilesEventCount }
|
||||
);
|
||||
|
||||
recentProcessedInternalFiles = [] as string[];
|
||||
@@ -125,7 +123,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
if (storageMTime == 0) {
|
||||
await this.deleteInternalFileOnDatabase(path);
|
||||
} else {
|
||||
await this.storeInternalFileToDatabase({ path: path, ...stat });
|
||||
await this.storeInternalFileToDatabase({ path: path, mtime, ctime: stat?.ctime ?? mtime, size: stat?.size ?? 0 });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -146,7 +144,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
Logger("something went wrong on resolving all conflicted internal files");
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
await this.conflictResolutionProcessor.startPipeline().waitForPipeline();
|
||||
await this.conflictResolutionProcessor.startPipeline().waitForAllProcessed();
|
||||
}
|
||||
|
||||
async resolveByNewerEntry(id: DocumentID, path: FilePathWithPrefix, currentDoc: EntryDoc, currentRev: string, conflictedRev: string) {
|
||||
@@ -162,7 +160,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
await this.localDatabase.removeRevision(id, delRev);
|
||||
Logger(`Older one has been deleted:${path}`);
|
||||
const cc = await this.localDatabase.getRaw(id, { conflicts: true });
|
||||
if (cc._conflicts.length == 0) {
|
||||
if (cc._conflicts?.length === 0) {
|
||||
await this.extractInternalFileFromDatabase(stripAllPrefixes(path))
|
||||
} else {
|
||||
this.conflictResolutionProcessor.enqueue(path);
|
||||
@@ -177,11 +175,12 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
// Retrieve data
|
||||
const id = await this.path2id(path, ICHeader);
|
||||
const doc = await this.localDatabase.getRaw(id, { conflicts: true });
|
||||
// If there is no conflict, return with false.
|
||||
if (!("_conflicts" in doc))
|
||||
return;
|
||||
// if (!("_conflicts" in doc)){
|
||||
// return [];
|
||||
// }
|
||||
if (doc._conflicts === undefined) return [];
|
||||
if (doc._conflicts.length == 0)
|
||||
return;
|
||||
return [];
|
||||
Logger(`Hidden file conflicted:${path}`);
|
||||
const conflicts = doc._conflicts.sort((a, b) => Number(a.split("-")[0]) - Number(b.split("-")[0]));
|
||||
const revA = doc._rev;
|
||||
@@ -192,7 +191,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
const conflictedRevNo = Number(conflictedRev.split("-")[0]);
|
||||
//Search
|
||||
const revFrom = (await this.localDatabase.getRaw<EntryDoc>(id, { revs_info: true }));
|
||||
const commonBase = revFrom._revs_info.filter(e => e.status == "available" && Number(e.rev.split("-")[0]) < conflictedRevNo).first()?.rev ?? "";
|
||||
const commonBase = revFrom._revs_info?.filter(e => e.status == "available" && Number(e.rev.split("-")[0]) < conflictedRevNo).first()?.rev ?? "";
|
||||
const result = await this.plugin.mergeObject(path, commonBase, doc._rev, conflictedRev);
|
||||
if (result) {
|
||||
Logger(`Object merge:${path}`, LOG_LEVEL_INFO);
|
||||
@@ -203,11 +202,14 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
}
|
||||
await this.plugin.vaultAccess.adapterWrite(filename, result);
|
||||
const stat = await this.vaultAccess.adapterStat(filename);
|
||||
if (!stat) {
|
||||
throw new Error(`conflictResolutionProcessor: Failed to stat file ${filename}`);
|
||||
}
|
||||
await this.storeInternalFileToDatabase({ path: filename, ...stat });
|
||||
await this.extractInternalFileFromDatabase(filename);
|
||||
await this.localDatabase.removeRevision(id, revB);
|
||||
this.conflictResolutionProcessor.enqueue(path);
|
||||
return;
|
||||
return [];
|
||||
} else {
|
||||
Logger(`Object merge is not applicable.`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
@@ -215,11 +217,11 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
}
|
||||
// When not JSON file, resolve conflicts by choosing a newer one.
|
||||
await this.resolveByNewerEntry(id, path, doc, revA, revB);
|
||||
return;
|
||||
return [];
|
||||
} catch (ex) {
|
||||
Logger(`Failed to resolve conflict (Hidden): ${path}`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
}, {
|
||||
suspended: false, batchSize: 1, concurrentLimit: 5, delay: 10, keepResultUntilDownstreamConnected: true, yieldThreshold: 10,
|
||||
@@ -312,11 +314,11 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
if (processed % 100 == 0) {
|
||||
Logger(`Hidden file: ${processed}/${fileCount}`, logLevel, "sync_internal");
|
||||
}
|
||||
if (!filename) return;
|
||||
if (!filename) return [];
|
||||
if (ignorePatterns.some(e => filename.match(e)))
|
||||
return;
|
||||
return [];
|
||||
if (await this.plugin.isIgnoredByIgnoreFiles(filename)) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileOnStorage = filename in filesMap ? filesMap[filename] : undefined;
|
||||
@@ -386,7 +388,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
}, { suspended: true, batchSize: 1, concurrentLimit: 5, delay: 0 }))
|
||||
.root
|
||||
.enqueueAll(allFileNames)
|
||||
.startPipeline().waitForPipeline();
|
||||
.startPipeline().waitForAllDoneAndTerminate();
|
||||
|
||||
await this.kvDB.set("diff-caches-internal", caches);
|
||||
|
||||
@@ -403,7 +405,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
const enabledPlugins = this.app.plugins.enabledPlugins as Set<string>;
|
||||
const enabledPluginManifests = manifests.filter(e => enabledPlugins.has(e.id));
|
||||
for (const manifest of enabledPluginManifests) {
|
||||
if (manifest.dir in updatedFolders) {
|
||||
if (manifest.dir && manifest.dir in updatedFolders) {
|
||||
// If notified about plug-ins, reloading Obsidian may not be necessary.
|
||||
updatedCount -= updatedFolders[manifest.dir];
|
||||
const updatePluginId = manifest.id;
|
||||
@@ -430,13 +432,14 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
|
||||
// If something changes left, notify for reloading Obsidian.
|
||||
if (updatedCount != 0) {
|
||||
this.plugin.askInPopup(`updated-any-hidden`, `Hidden files have been synchronized, Press {HERE} to reload Obsidian, or press elsewhere to dismiss this message.`, (anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
// @ts-ignore
|
||||
this.app.commands.executeCommandById("app:reload");
|
||||
if (!this.plugin.isReloadingScheduled) {
|
||||
this.plugin.askInPopup(`updated-any-hidden`, `Hidden files have been synchronised, Press {HERE} to schedule a reload of Obsidian, or press elsewhere to dismiss this message.`, (anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
this.plugin.scheduleAppReload();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,19 +454,11 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
|
||||
const id = await this.path2id(file.path, ICHeader);
|
||||
const prefixedFileName = addPrefix(file.path, ICHeader);
|
||||
const contentBin = await this.plugin.vaultAccess.adapterReadBinary(file.path);
|
||||
let content: Blob;
|
||||
try {
|
||||
content = createBinaryBlob(contentBin);
|
||||
} catch (ex) {
|
||||
Logger(`The file ${file.path} could not be encoded`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const content = createBlob(await this.plugin.vaultAccess.adapterReadAuto(file.path));
|
||||
const mtime = file.mtime;
|
||||
return await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
const old = await this.localDatabase.getDBEntry(prefixedFileName, null, false, false);
|
||||
const old = await this.localDatabase.getDBEntry(prefixedFileName, undefined, false, false);
|
||||
let saveData: SavingEntry;
|
||||
if (old === false) {
|
||||
saveData = {
|
||||
@@ -477,9 +472,10 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
children: [],
|
||||
deleted: false,
|
||||
type: "newnote",
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (await isDocContentSame(createBinaryBlob(decodeBinary(old.data)), content) && !forceWrite) {
|
||||
if (await isDocContentSame(readAsBlob(old), content) && !forceWrite) {
|
||||
// Logger(`STORAGE --> DB:${file.path}: (hidden) Not changed`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
@@ -489,10 +485,10 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
data: content,
|
||||
mtime,
|
||||
size: file.size,
|
||||
datatype: "newnote",
|
||||
datatype: old.datatype,
|
||||
children: [],
|
||||
deleted: false,
|
||||
type: "newnote",
|
||||
type: old.datatype,
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
@@ -515,7 +511,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
}
|
||||
await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
const old = await this.localDatabase.getDBEntryMeta(prefixedFileName, null, true) as InternalFileEntry | false;
|
||||
const old = await this.localDatabase.getDBEntryMeta(prefixedFileName, undefined, true) as InternalFileEntry | false;
|
||||
let saveData: InternalFileEntry;
|
||||
if (old === false) {
|
||||
saveData = {
|
||||
@@ -527,11 +523,12 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
children: [],
|
||||
deleted: true,
|
||||
type: "newnote",
|
||||
eden: {}
|
||||
};
|
||||
} else {
|
||||
// Remove all conflicted before deleting.
|
||||
const conflicts = await this.localDatabase.getRaw(old._id, { conflicts: true });
|
||||
if ("_conflicts" in conflicts) {
|
||||
if (conflicts._conflicts !== undefined) {
|
||||
for (const conflictRev of conflicts._conflicts) {
|
||||
await this.localDatabase.removeRevision(old._id, conflictRev);
|
||||
Logger(`STORAGE -x> DB:${filename}: (hidden) conflict removed ${old._rev} => ${conflictRev}`, LOG_LEVEL_VERBOSE);
|
||||
@@ -581,7 +578,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
const deleted = fileOnDB.deleted || fileOnDB._deleted || false;
|
||||
if (deleted) {
|
||||
if (!isExists) {
|
||||
Logger(`STORAGE <x- DB:${filename}: deleted (hidden) Deleted on DB, but the file is already not found on storage.`);
|
||||
Logger(`STORAGE <x- DB:${filename}: deleted (hidden) Deleted on DB, but the file is already not found on storage.`);
|
||||
} else {
|
||||
Logger(`STORAGE <x- DB:${filename}: deleted (hidden).`);
|
||||
await this.plugin.vaultAccess.adapterRemove(filename);
|
||||
@@ -597,7 +594,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
}
|
||||
if (!isExists) {
|
||||
await this.vaultAccess.ensureDirectory(filename);
|
||||
await this.plugin.vaultAccess.adapterWrite(filename, decodeBinary(fileOnDB.data), { mtime: fileOnDB.mtime, ctime: fileOnDB.ctime });
|
||||
await this.plugin.vaultAccess.adapterWrite(filename, readContent(fileOnDB), { mtime: fileOnDB.mtime, ctime: fileOnDB.ctime });
|
||||
try {
|
||||
//@ts-ignore internalAPI
|
||||
await this.app.vault.adapter.reconcileInternalFile(filename);
|
||||
@@ -608,13 +605,13 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
Logger(`STORAGE <-- DB:${filename}: written (hidden,new${force ? ", force" : ""})`);
|
||||
return true;
|
||||
} else {
|
||||
const contentBin = await this.plugin.vaultAccess.adapterReadBinary(filename);
|
||||
const content = await encodeBinary(contentBin);
|
||||
if (await isDocContentSame(content, fileOnDB.data) && !force) {
|
||||
const content = await this.plugin.vaultAccess.adapterReadAuto(filename);
|
||||
const docContent = readContent(fileOnDB);
|
||||
if (await isDocContentSame(content, docContent) && !force) {
|
||||
// Logger(`STORAGE <-- DB:${filename}: skipped (hidden) Not changed`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
await this.plugin.vaultAccess.adapterWrite(filename, decodeBinary(fileOnDB.data), { mtime: fileOnDB.mtime, ctime: fileOnDB.ctime });
|
||||
await this.plugin.vaultAccess.adapterWrite(filename, docContent, { mtime: fileOnDB.mtime, ctime: fileOnDB.ctime });
|
||||
try {
|
||||
//@ts-ignore internalAPI
|
||||
await this.app.vault.adapter.reconcileInternalFile(filename);
|
||||
@@ -669,7 +666,11 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
}
|
||||
await this.plugin.vaultAccess.adapterWrite(filename, result);
|
||||
const stat = await this.plugin.vaultAccess.adapterStat(filename);
|
||||
await this.storeInternalFileToDatabase({ path: filename, ...stat }, true);
|
||||
if (!stat) {
|
||||
throw new Error("Stat failed");
|
||||
}
|
||||
const mtime = stat?.mtime ?? 0;
|
||||
await this.storeInternalFileToDatabase({ path: filename, mtime, ctime: stat?.ctime ?? mtime, size: stat?.size ?? 0 }, true);
|
||||
try {
|
||||
//@ts-ignore internalAPI
|
||||
await this.app.vault.adapter.reconcileInternalFile(filename);
|
||||
@@ -703,7 +704,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
const root = this.app.vault.getRoot();
|
||||
const findRoot = root.path;
|
||||
|
||||
const filenames = (await this.getFiles(findRoot, [], null, ignoreFilter)).filter(e => e.startsWith(".")).filter(e => !e.startsWith(".trash"));
|
||||
const filenames = (await this.getFiles(findRoot, [], undefined, ignoreFilter)).filter(e => e.startsWith(".")).filter(e => !e.startsWith(".trash"));
|
||||
const files = filenames.filter(path => synchronisedInConfigSync.every(filterFile => !path.toLowerCase().startsWith(filterFile))).map(async (e) => {
|
||||
return {
|
||||
path: e as FilePath,
|
||||
@@ -716,9 +717,12 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
if (await this.plugin.isIgnoredByIgnoreFiles(w.path)) {
|
||||
continue
|
||||
}
|
||||
const mtime = w.stat?.mtime ?? 0
|
||||
const ctime = w.stat?.ctime ?? mtime;
|
||||
const size = w.stat?.size ?? 0;
|
||||
result.push({
|
||||
...w,
|
||||
...w.stat
|
||||
mtime, ctime, size
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -729,8 +733,8 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
async getFiles(
|
||||
path: string,
|
||||
ignoreList: string[],
|
||||
filter: RegExp[],
|
||||
ignoreFilter: RegExp[]
|
||||
filter?: RegExp[],
|
||||
ignoreFilter?: RegExp[]
|
||||
) {
|
||||
let w: ListedFiles;
|
||||
try {
|
||||
@@ -1,14 +1,15 @@
|
||||
import { type EntryDoc, type ObsidianLiveSyncSettings, DEFAULT_SETTINGS, LOG_LEVEL_NOTICE } from "./lib/src/types";
|
||||
import { configURIBase } from "./types";
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import { PouchDB } from "./lib/src/pouchdb-browser.js";
|
||||
import { askSelectString, askYesNo, askString } from "./utils";
|
||||
import { decrypt, encrypt } from "./lib/src/e2ee_v2";
|
||||
import { LiveSyncCommands } from "./LiveSyncCommands";
|
||||
import { delay, fireAndForget } from "./lib/src/utils";
|
||||
import { confirmWithMessage } from "./dialogs";
|
||||
import { Platform } from "./deps";
|
||||
import { fetchAllUsedChunks } from "./lib/src/utils_couchdb";
|
||||
import { type EntryDoc, type ObsidianLiveSyncSettings, DEFAULT_SETTINGS, LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO } from "../lib/src/common/types.ts";
|
||||
import { configURIBase } from "../common/types.ts";
|
||||
import { Logger } from "../lib/src/common/logger.ts";
|
||||
import { PouchDB } from "../lib/src/pouchdb/pouchdb-browser.js";
|
||||
import { askSelectString, askYesNo, askString } from "../common/utils.ts";
|
||||
import { decrypt, encrypt } from "../lib/src/encryption/e2ee_v2.ts";
|
||||
import { LiveSyncCommands } from "./LiveSyncCommands.ts";
|
||||
import { delay, fireAndForget } from "../lib/src/common/utils.ts";
|
||||
import { confirmWithMessage } from "../common/dialogs.ts";
|
||||
import { Platform } from "../deps.ts";
|
||||
import { fetchAllUsedChunks } from "../lib/src/pouchdb/utils_couchdb.ts";
|
||||
import type { LiveSyncCouchDBReplicator } from "../lib/src/replication/couchdb/LiveSyncReplicator.js";
|
||||
|
||||
export class SetupLiveSync extends LiveSyncCommands {
|
||||
onunload() { }
|
||||
@@ -50,7 +51,7 @@ export class SetupLiveSync extends LiveSyncCommands {
|
||||
const encryptingPassphrase = await askString(this.app, "Encrypt your settings", "The passphrase to encrypt the setup URI", "", true);
|
||||
if (encryptingPassphrase === false)
|
||||
return;
|
||||
const setting = { ...this.settings, configPassphraseStore: "", encryptedCouchDBConnection: "", encryptedPassphrase: "" };
|
||||
const setting = { ...this.settings, configPassphraseStore: "", encryptedCouchDBConnection: "", encryptedPassphrase: "" } as Partial<ObsidianLiveSyncSettings>;
|
||||
if (stripExtra) {
|
||||
delete setting.pluginSyncExtendedSetting;
|
||||
}
|
||||
@@ -134,7 +135,7 @@ export class SetupLiveSync extends LiveSyncCommands {
|
||||
} else if (setupType == setupAsMerge) {
|
||||
this.plugin.settings = newSettingW;
|
||||
this.plugin.usedPassphrase = "";
|
||||
await this.fetchLocalWithKeepLocal();
|
||||
await this.fetchLocalWithRebuild();
|
||||
} else if (setupType == setupAgain) {
|
||||
const confirm = "I know this operation will rebuild all my databases with files on this device, and files that are on the remote database and I didn't synchronize to any other devices will be lost and want to proceed indeed.";
|
||||
if (await askSelectString(this.app, "Do you really want to do this?", ["Cancel", confirm]) != confirm) {
|
||||
@@ -311,6 +312,7 @@ Of course, we are able to disable these features.`
|
||||
}
|
||||
async suspendReflectingDatabase() {
|
||||
if (this.plugin.settings.doNotSuspendOnFetching) return;
|
||||
if (this.plugin.settings.remoteType == REMOTE_MINIO) return;
|
||||
Logger(`Suspending reflection: Database and storage changes will not be reflected in each other until completely finished the fetching.`, LOG_LEVEL_NOTICE);
|
||||
this.plugin.settings.suspendParseReplicationResult = true;
|
||||
this.plugin.settings.suspendFileWatching = true;
|
||||
@@ -318,6 +320,7 @@ Of course, we are able to disable these features.`
|
||||
}
|
||||
async resumeReflectingDatabase() {
|
||||
if (this.plugin.settings.doNotSuspendOnFetching) return;
|
||||
if (this.plugin.settings.remoteType == REMOTE_MINIO) return;
|
||||
Logger(`Database and storage reflection has been resumed!`, LOG_LEVEL_NOTICE);
|
||||
this.plugin.settings.suspendParseReplicationResult = false;
|
||||
this.plugin.settings.suspendFileWatching = false;
|
||||
@@ -348,9 +351,10 @@ Of course, we are able to disable these features.`
|
||||
await this.plugin.resetLocalDatabase();
|
||||
}
|
||||
async fetchRemoteChunks() {
|
||||
if (!this.plugin.settings.doNotSuspendOnFetching && this.plugin.settings.readChunksOnline) {
|
||||
if (!this.plugin.settings.doNotSuspendOnFetching && this.plugin.settings.readChunksOnline && this.plugin.settings.remoteType == REMOTE_COUCHDB) {
|
||||
Logger(`Fetching chunks`, LOG_LEVEL_NOTICE);
|
||||
const remoteDB = await this.plugin.getReplicator().connectRemoteCouchDBWithSetting(this.settings, this.plugin.getIsMobile(), true);
|
||||
const replicator = this.plugin.getReplicator() as LiveSyncCouchDBReplicator;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(this.settings, this.plugin.getIsMobile(), true);
|
||||
if (typeof remoteDB == "string") {
|
||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
@@ -377,11 +381,10 @@ Of course, we are able to disable these features.`
|
||||
await this.plugin.replicateAllFromServer(true);
|
||||
await delay(1000);
|
||||
await this.plugin.replicateAllFromServer(true);
|
||||
await this.fetchRemoteChunks();
|
||||
await this.resumeReflectingDatabase();
|
||||
await this.askHiddenFileConfiguration({ enableFetch: true });
|
||||
}
|
||||
async fetchLocalWithKeepLocal() {
|
||||
async fetchLocalWithRebuild() {
|
||||
return await this.fetchLocal(true);
|
||||
}
|
||||
async rebuildRemote() {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type AnyEntry, type DocumentID, type EntryDoc, type EntryHasPath, type FilePath, type FilePathWithPrefix } from "./lib/src/types";
|
||||
import { PouchDB } from "./lib/src/pouchdb-browser.js";
|
||||
import type ObsidianLiveSyncPlugin from "./main";
|
||||
import { type AnyEntry, type DocumentID, type EntryDoc, type EntryHasPath, type FilePath, type FilePathWithPrefix } from "../lib/src/common/types.ts";
|
||||
import { PouchDB } from "../lib/src/pouchdb/pouchdb-browser.js";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
|
||||
|
||||
export abstract class LiveSyncCommands {
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 5269544598...f0253a8548
1267
src/main.ts
1267
src/main.ts
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
||||
import { type App, TFile, type DataWriteOptions, TFolder, TAbstractFile } from "./deps";
|
||||
import { serialized } from "./lib/src/lock";
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import type { FilePath } from "./lib/src/types";
|
||||
import { createBinaryBlob, isDocContentSame } from "./lib/src/utils";
|
||||
import type { InternalFileInfo } from "./types";
|
||||
import { markChangesAreSame } from "./utils";
|
||||
import { type App, TFile, type DataWriteOptions, TFolder, TAbstractFile } from "../deps.ts";
|
||||
import { serialized } from "../lib/src/concurrency/lock.ts";
|
||||
import { Logger } from "../lib/src/common/logger.ts";
|
||||
import { isPlainText } from "../lib/src/string_and_binary/path.ts";
|
||||
import type { FilePath } from "../lib/src/common/types.ts";
|
||||
import { createBinaryBlob, isDocContentSame } from "../lib/src/common/utils.ts";
|
||||
import type { InternalFileInfo } from "../common/types.ts";
|
||||
import { markChangesAreSame } from "../common/utils.ts";
|
||||
|
||||
function getFileLockKey(file: TFile | TFolder | string) {
|
||||
return `fl:${typeof (file) == "string" ? file : file.path}`;
|
||||
@@ -56,6 +57,12 @@ export class SerializedFileAccess {
|
||||
return await processReadFile(file, () => this.app.vault.adapter.readBinary(path));
|
||||
}
|
||||
|
||||
async adapterReadAuto(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
if (isPlainText(path)) return await processReadFile(file, () => this.app.vault.adapter.read(path));
|
||||
return await processReadFile(file, () => this.app.vault.adapter.readBinary(path));
|
||||
}
|
||||
|
||||
async adapterWrite(file: TFile | string, data: string | ArrayBuffer | Uint8Array, options?: DataWriteOptions) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
if (typeof (data) === "string") {
|
||||
@@ -77,12 +84,19 @@ export class SerializedFileAccess {
|
||||
return await processReadFile(file, () => this.app.vault.readBinary(file));
|
||||
}
|
||||
|
||||
async vaultReadAuto(file: TFile) {
|
||||
const path = file.path;
|
||||
if (isPlainText(path)) return await processReadFile(file, () => this.app.vault.read(file));
|
||||
return await processReadFile(file, () => this.app.vault.readBinary(file));
|
||||
}
|
||||
|
||||
|
||||
async vaultModify(file: TFile, data: string | ArrayBuffer | Uint8Array, options?: DataWriteOptions) {
|
||||
if (typeof (data) === "string") {
|
||||
return await processWriteFile(file, async () => {
|
||||
const oldData = await this.app.vault.read(file);
|
||||
if (data === oldData) {
|
||||
markChangesAreSame(file, file.stat.mtime, options.mtime);
|
||||
if (options && options.mtime) markChangesAreSame(file, file.stat.mtime, options.mtime);
|
||||
return false
|
||||
}
|
||||
await this.app.vault.modify(file, data, options)
|
||||
@@ -93,7 +107,7 @@ export class SerializedFileAccess {
|
||||
return await processWriteFile(file, async () => {
|
||||
const oldData = await this.app.vault.readBinary(file);
|
||||
if (await isDocContentSame(createBinaryBlob(oldData), createBinaryBlob(data))) {
|
||||
markChangesAreSame(file, file.stat.mtime, options.mtime);
|
||||
if (options && options.mtime) markChangesAreSame(file, file.stat.mtime, options.mtime);
|
||||
return false;
|
||||
}
|
||||
await this.app.vault.modifyBinary(file, toArrayBuffer(data), options)
|
||||
@@ -149,10 +163,9 @@ export class SerializedFileAccess {
|
||||
c += v;
|
||||
try {
|
||||
await this.app.vault.adapter.mkdir(c);
|
||||
} catch (ex) {
|
||||
// basically skip exceptions.
|
||||
if (ex.message && ex.message == "Folder already exists.") {
|
||||
// especially this message is.
|
||||
} catch (ex: any) {
|
||||
if (ex?.message == "Folder already exists.") {
|
||||
// Skip if already exists.
|
||||
} else {
|
||||
Logger("Folder Create Error");
|
||||
Logger(ex);
|
||||
307
src/storages/StorageEventManager.ts
Normal file
307
src/storages/StorageEventManager.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import type { SerializedFileAccess } from "./SerializedFileAccess.ts";
|
||||
import { Plugin, TAbstractFile, TFile, TFolder } from "../deps.ts";
|
||||
import { Logger } from "../lib/src/common/logger.ts";
|
||||
import { shouldBeIgnored } from "../lib/src/string_and_binary/path.ts";
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, type FilePath, type ObsidianLiveSyncSettings } from "../lib/src/common/types.ts";
|
||||
import { delay, fireAndForget } from "../lib/src/common/utils.ts";
|
||||
import { type FileEventItem, type FileEventType, type FileInfo, type InternalFileInfo } from "../common/types.ts";
|
||||
import { skipIfDuplicated } from "../lib/src/concurrency/lock.ts";
|
||||
import { finishAllWaitingForTimeout, finishWaitingForTimeout, isWaitingForTimeout, waitForTimeout } from "../lib/src/concurrency/task.ts";
|
||||
import { reactiveSource, type ReactiveSource } from "../lib/src/dataobject/reactive.ts";
|
||||
import { Semaphore } from "../lib/src/concurrency/semaphore.ts";
|
||||
|
||||
|
||||
export type FileEvent = {
|
||||
type: FileEventType;
|
||||
file: TAbstractFile | InternalFileInfo;
|
||||
oldPath?: string;
|
||||
cachedData?: string;
|
||||
skipBatchWait?: boolean;
|
||||
};
|
||||
|
||||
|
||||
export abstract class StorageEventManager {
|
||||
abstract beginWatch(): void;
|
||||
abstract flushQueue(): void;
|
||||
abstract appendQueue(items: FileEvent[], ctx?: any): void;
|
||||
abstract cancelQueue(key: string): void;
|
||||
abstract isWaiting(filename: FilePath): boolean;
|
||||
abstract totalQueued: ReactiveSource<number>;
|
||||
abstract batched: ReactiveSource<number>;
|
||||
abstract processing: ReactiveSource<number>;
|
||||
|
||||
}
|
||||
|
||||
type LiveSyncForStorageEventManager = Plugin &
|
||||
{
|
||||
settings: ObsidianLiveSyncSettings
|
||||
ignoreFiles: string[],
|
||||
vaultAccess: SerializedFileAccess
|
||||
shouldBatchSave: boolean
|
||||
batchSaveMinimumDelay: number;
|
||||
batchSaveMaximumDelay: number;
|
||||
|
||||
} & {
|
||||
isTargetFile: (file: string | TAbstractFile) => Promise<boolean>,
|
||||
// fileEventQueue: QueueProcessor<FileEventItem, any>,
|
||||
handleFileEvent: (queue: FileEventItem) => Promise<any>,
|
||||
isFileSizeExceeded: (size: number) => boolean;
|
||||
};
|
||||
|
||||
|
||||
export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
totalQueued = reactiveSource(0);
|
||||
batched = reactiveSource(0);
|
||||
processing = reactiveSource(0);
|
||||
plugin: LiveSyncForStorageEventManager;
|
||||
|
||||
get shouldBatchSave() {
|
||||
return this.plugin.shouldBatchSave;
|
||||
}
|
||||
get batchSaveMinimumDelay(): number {
|
||||
return this.plugin.batchSaveMinimumDelay;
|
||||
}
|
||||
get batchSaveMaximumDelay(): number {
|
||||
return this.plugin.batchSaveMaximumDelay
|
||||
}
|
||||
constructor(plugin: LiveSyncForStorageEventManager) {
|
||||
super();
|
||||
this.plugin = plugin;
|
||||
}
|
||||
beginWatch() {
|
||||
const plugin = this.plugin;
|
||||
this.watchVaultChange = this.watchVaultChange.bind(this);
|
||||
this.watchVaultCreate = this.watchVaultCreate.bind(this);
|
||||
this.watchVaultDelete = this.watchVaultDelete.bind(this);
|
||||
this.watchVaultRename = this.watchVaultRename.bind(this);
|
||||
this.watchVaultRawEvents = this.watchVaultRawEvents.bind(this);
|
||||
plugin.registerEvent(plugin.app.vault.on("modify", this.watchVaultChange));
|
||||
plugin.registerEvent(plugin.app.vault.on("delete", this.watchVaultDelete));
|
||||
plugin.registerEvent(plugin.app.vault.on("rename", this.watchVaultRename));
|
||||
plugin.registerEvent(plugin.app.vault.on("create", this.watchVaultCreate));
|
||||
//@ts-ignore : Internal API
|
||||
plugin.registerEvent(plugin.app.vault.on("raw", this.watchVaultRawEvents));
|
||||
// plugin.fileEventQueue.startPipeline();
|
||||
}
|
||||
|
||||
watchVaultCreate(file: TAbstractFile, ctx?: any) {
|
||||
this.appendQueue([{ type: "CREATE", file }], ctx);
|
||||
}
|
||||
|
||||
watchVaultChange(file: TAbstractFile, ctx?: any) {
|
||||
this.appendQueue([{ type: "CHANGED", file }], ctx);
|
||||
}
|
||||
|
||||
watchVaultDelete(file: TAbstractFile, ctx?: any) {
|
||||
this.appendQueue([{ type: "DELETE", file }], ctx);
|
||||
}
|
||||
watchVaultRename(file: TAbstractFile, oldFile: string, ctx?: any) {
|
||||
if (file instanceof TFile) {
|
||||
this.appendQueue([
|
||||
{ type: "DELETE", file: { path: oldFile as FilePath, mtime: file.stat.mtime, ctime: file.stat.ctime, size: file.stat.size, deleted: true }, skipBatchWait: true },
|
||||
{ type: "CREATE", file, skipBatchWait: true },
|
||||
], ctx);
|
||||
}
|
||||
}
|
||||
// Watch raw events (Internal API)
|
||||
watchVaultRawEvents(path: FilePath) {
|
||||
if (this.plugin.settings.useIgnoreFiles && this.plugin.ignoreFiles.some(e => path.endsWith(e.trim()))) {
|
||||
// If it is one of ignore files, refresh the cached one.
|
||||
this.plugin.isTargetFile(path).then(() => this._watchVaultRawEvents(path));
|
||||
} else {
|
||||
this._watchVaultRawEvents(path);
|
||||
}
|
||||
}
|
||||
|
||||
_watchVaultRawEvents(path: FilePath) {
|
||||
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;
|
||||
this.appendQueue(
|
||||
[{
|
||||
type: "INTERNAL",
|
||||
file: { path, mtime: 0, ctime: 0, size: 0 }
|
||||
}], null);
|
||||
}
|
||||
// Cache file and waiting to can be proceed.
|
||||
async appendQueue(params: FileEvent[], ctx?: any) {
|
||||
if (!this.plugin.settings.isConfigured) return;
|
||||
if (this.plugin.settings.suspendFileWatching) return;
|
||||
const processFiles = new Set<FilePath>();
|
||||
for (const param of params) {
|
||||
if (shouldBeIgnored(param.file.path)) {
|
||||
continue;
|
||||
}
|
||||
const atomicKey = [0, 0, 0, 0, 0, 0].map(e => `${Math.floor(Math.random() * 100000)}`).join("-");
|
||||
const type = param.type;
|
||||
const file = param.file;
|
||||
const oldPath = param.oldPath;
|
||||
const size = file instanceof TFile ? file.stat.size : (file as InternalFileInfo)?.size ?? 0;
|
||||
if (this.plugin.isFileSizeExceeded(size) && (type == "CREATE" || type == "CHANGED")) {
|
||||
Logger(`The storage file has been changed but exceeds the maximum size. Skipping: ${param.file.path}`, LOG_LEVEL_NOTICE);
|
||||
continue;
|
||||
}
|
||||
if (file instanceof TFolder) continue;
|
||||
if (!await this.plugin.isTargetFile(file.path)) continue;
|
||||
|
||||
// Stop cache using to prevent the corruption;
|
||||
// let cache: null | string | ArrayBuffer;
|
||||
// new file or something changed, cache the changes.
|
||||
if (file instanceof TFile && (type == "CREATE" || type == "CHANGED")) {
|
||||
// Wait for a bit while to let the writer has marked `touched` at the file.
|
||||
await delay(10);
|
||||
if (this.plugin.vaultAccess.recentlyTouched(file)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const fileInfo = file instanceof TFile ? {
|
||||
ctime: file.stat.ctime,
|
||||
mtime: file.stat.mtime,
|
||||
file: file,
|
||||
path: file.path,
|
||||
size: file.stat.size
|
||||
} as FileInfo : file as InternalFileInfo;
|
||||
let cache: string | undefined = undefined;
|
||||
if (param.cachedData) {
|
||||
cache = param.cachedData
|
||||
}
|
||||
this.enqueue({
|
||||
type,
|
||||
args: {
|
||||
file: fileInfo,
|
||||
oldPath,
|
||||
cache,
|
||||
ctx,
|
||||
},
|
||||
skipBatchWait: param.skipBatchWait,
|
||||
key: atomicKey
|
||||
})
|
||||
processFiles.add(file.path as FilePath);
|
||||
if (oldPath) {
|
||||
processFiles.add(oldPath as FilePath);
|
||||
}
|
||||
}
|
||||
for (const path of processFiles) {
|
||||
fireAndForget(() => this.startStandingBy(path));
|
||||
}
|
||||
}
|
||||
bufferedQueuedItems = [] as FileEventItem[];
|
||||
|
||||
enqueue(newItem: FileEventItem) {
|
||||
const filename = newItem.args.file.path;
|
||||
if (this.shouldBatchSave) {
|
||||
Logger(`Request cancel for waiting of previous ${filename}`, LOG_LEVEL_DEBUG);
|
||||
finishWaitingForTimeout(`storage-event-manager-batchsave-${filename}`);
|
||||
}
|
||||
this.bufferedQueuedItems.push(newItem);
|
||||
// When deleting or renaming, the queue must be flushed once before processing subsequent processes to prevent unexpected race condition.
|
||||
if (newItem.type == "DELETE" || newItem.type == "RENAME") {
|
||||
return this.flushQueue();
|
||||
}
|
||||
}
|
||||
concurrentProcessing = Semaphore(5);
|
||||
waitedSince = new Map<FilePath, number>();
|
||||
async startStandingBy(filename: FilePath) {
|
||||
// If waited, cancel previous waiting.
|
||||
await skipIfDuplicated(`storage-event-manager-${filename}`, async () => {
|
||||
Logger(`Processing ${filename}: Starting`, LOG_LEVEL_DEBUG);
|
||||
const release = await this.concurrentProcessing.acquire();
|
||||
try {
|
||||
Logger(`Processing ${filename}: Started`, LOG_LEVEL_DEBUG);
|
||||
let noMoreFiles = false;
|
||||
do {
|
||||
const target = this.bufferedQueuedItems.find(e => e.args.file.path == filename);
|
||||
if (target === undefined) {
|
||||
noMoreFiles = true;
|
||||
break;
|
||||
}
|
||||
const operationType = target.type;
|
||||
|
||||
// if (target.waitedFrom + this.batchSaveMaximumDelay > now) {
|
||||
// this.requestProcessQueue(target);
|
||||
// continue;
|
||||
// }
|
||||
const type = target.type;
|
||||
if (target.cancelled) {
|
||||
Logger(`Processing ${filename}: Cancelled (scheduled): ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
this.cancelStandingBy(target);
|
||||
continue;
|
||||
}
|
||||
if (!target.skipBatchWait) {
|
||||
if (this.shouldBatchSave && (type == "CREATE" || type == "CHANGED")) {
|
||||
const waitedSince = this.waitedSince.get(filename);
|
||||
let canWait = true;
|
||||
const now = Date.now();
|
||||
if (waitedSince !== undefined) {
|
||||
if (waitedSince + (this.batchSaveMaximumDelay * 1000) < now) {
|
||||
Logger(`Processing ${filename}: Could not wait no more: ${operationType}`, LOG_LEVEL_INFO)
|
||||
canWait = false;
|
||||
}
|
||||
}
|
||||
if (canWait) {
|
||||
if (waitedSince === undefined) this.waitedSince.set(filename, now)
|
||||
target.batched = true
|
||||
Logger(`Processing ${filename}: Waiting for batch save delay: ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
this.updateStatus();
|
||||
const result = await waitForTimeout(`storage-event-manager-batchsave-${filename}`, this.batchSaveMinimumDelay * 1000);
|
||||
if (!result) {
|
||||
Logger(`Processing ${filename}: Cancelled by new queue: ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
// If could not wait for the timeout, possibly we got a new queue. therefore, currently processing one should be cancelled
|
||||
this.cancelStandingBy(target);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger(`Processing ${filename}:Requested to perform immediately ${filename}: ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
}
|
||||
Logger(`Processing ${filename}: Request main to process: ${operationType}`, LOG_LEVEL_DEBUG)
|
||||
this.requestProcessQueue(target);
|
||||
} while (!noMoreFiles)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
Logger(`Processing ${filename}: Finished`, LOG_LEVEL_DEBUG);
|
||||
})
|
||||
}
|
||||
|
||||
cancelStandingBy(fei: FileEventItem) {
|
||||
this.bufferedQueuedItems.remove(fei);
|
||||
this.updateStatus();
|
||||
}
|
||||
processingCount = 0;
|
||||
async requestProcessQueue(fei: FileEventItem) {
|
||||
try {
|
||||
this.processingCount++;
|
||||
this.bufferedQueuedItems.remove(fei);
|
||||
this.updateStatus()
|
||||
this.waitedSince.delete(fei.args.file.path);
|
||||
await this.plugin.handleFileEvent(fei);
|
||||
} finally {
|
||||
this.processingCount--;
|
||||
this.updateStatus()
|
||||
}
|
||||
}
|
||||
isWaiting(filename: FilePath) {
|
||||
return isWaitingForTimeout(`storage-event-manager-batchsave-${filename}`);
|
||||
}
|
||||
flushQueue() {
|
||||
this.bufferedQueuedItems.forEach(e => e.skipBatchWait = true)
|
||||
finishAllWaitingForTimeout("storage-event-manager-batchsave-", true);
|
||||
}
|
||||
cancelQueue(key: string) {
|
||||
this.bufferedQueuedItems.forEach(e => {
|
||||
if (e.key === key) e.skipBatchWait = true
|
||||
})
|
||||
}
|
||||
updateStatus() {
|
||||
const allItems = this.bufferedQueuedItems.filter(e => !e.cancelled)
|
||||
this.batched.value = allItems.filter(e => e.batched && !e.skipBatchWait).length;
|
||||
this.processing.value = this.processingCount;
|
||||
this.totalQueued.value = allItems.length - this.batched.value;
|
||||
}
|
||||
}
|
||||
50
src/tests/TestPane.svelte
Normal file
50
src/tests/TestPane.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import type ObsidianLiveSyncPlugin from "../main";
|
||||
import { perf_trench } from "./tests";
|
||||
import { MarkdownRenderer } from "../deps";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
let performanceTestResult = "";
|
||||
let functionCheckResult = "";
|
||||
let testRunning = false;
|
||||
let prefTestResultEl: HTMLDivElement;
|
||||
let isReady = false;
|
||||
$: {
|
||||
if (performanceTestResult != "" && isReady) {
|
||||
MarkdownRenderer.render(plugin.app, performanceTestResult, prefTestResultEl, "/", plugin);
|
||||
}
|
||||
}
|
||||
|
||||
async function performTest() {
|
||||
try {
|
||||
testRunning = true;
|
||||
performanceTestResult = await perf_trench(plugin);
|
||||
} finally {
|
||||
testRunning = false;
|
||||
}
|
||||
}
|
||||
function clearPerfTestResult() {
|
||||
prefTestResultEl.empty();
|
||||
}
|
||||
onMount(() => {
|
||||
isReady = true;
|
||||
// performTest();
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2>TESTBENCH: Self-hosted LiveSync</h2>
|
||||
|
||||
<h3>Function check</h3>
|
||||
<pre>{functionCheckResult}</pre>
|
||||
|
||||
<h3>Performance test</h3>
|
||||
<button on:click={() => performTest()} disabled={testRunning}>Test!</button>
|
||||
<button on:click={() => clearPerfTestResult()}>Clear</button>
|
||||
|
||||
<div bind:this={prefTestResultEl}></div>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
49
src/tests/TestPaneView.ts
Normal file
49
src/tests/TestPaneView.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
ItemView,
|
||||
WorkspaceLeaf
|
||||
} from "obsidian";
|
||||
import TestPaneComponent from "./TestPane.svelte"
|
||||
import type ObsidianLiveSyncPlugin from "../main"
|
||||
export const VIEW_TYPE_TEST = "ols-pane-test";
|
||||
//Log view
|
||||
export class TestPaneView extends ItemView {
|
||||
|
||||
component?: TestPaneComponent;
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon = "view-log";
|
||||
title: string = "Self-hosted LiveSync Test and Results"
|
||||
navigation = true;
|
||||
|
||||
getIcon(): string {
|
||||
return "view-log";
|
||||
}
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: ObsidianLiveSyncPlugin) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
|
||||
getViewType() {
|
||||
return VIEW_TYPE_TEST;
|
||||
}
|
||||
|
||||
getDisplayText() {
|
||||
return "Self-hosted LiveSync Test and Results";
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
async onOpen() {
|
||||
this.component = new TestPaneComponent({
|
||||
target: this.contentEl,
|
||||
props: {
|
||||
plugin: this.plugin
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
async onClose() {
|
||||
this.component?.$destroy();
|
||||
}
|
||||
}
|
||||
45
src/tests/testUtils.ts
Normal file
45
src/tests/testUtils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { fireAndForget } from "src/lib/src/common/utils";
|
||||
import { serialized } from "src/lib/src/concurrency/lock";
|
||||
import type ObsidianLiveSyncPlugin from "src/main";
|
||||
|
||||
let plugin: ObsidianLiveSyncPlugin;
|
||||
export function enableTestFunction(plugin_: ObsidianLiveSyncPlugin) {
|
||||
plugin = plugin_;
|
||||
}
|
||||
export function addDebugFileLog(message: any, stackLog = false) {
|
||||
fireAndForget(serialized("debug-log", async () => {
|
||||
const now = new Date();
|
||||
const filename = `debug-log`
|
||||
const time = now.toISOString().split("T")[0];
|
||||
const outFile = `${filename}${time}.jsonl`;
|
||||
// const messageContent = typeof message == "string" ? message : message instanceof Error ? `${message.name}:${message.message}` : JSON.stringify(message, null, 2);
|
||||
const timestamp = now.toLocaleString();
|
||||
const timestampEpoch = now;
|
||||
let out = { "timestamp": timestamp, epoch: timestampEpoch, } as Record<string, any>;
|
||||
if (message instanceof Error) {
|
||||
// debugger;
|
||||
// console.dir(message.stack);
|
||||
out = { ...out, message };
|
||||
} else if (stackLog) {
|
||||
if (stackLog) {
|
||||
const stackE = new Error();
|
||||
const stack = stackE.stack;
|
||||
out = { ...out, stack }
|
||||
}
|
||||
}
|
||||
if (typeof message == "object") {
|
||||
out = { ...out, ...message, }
|
||||
} else {
|
||||
out = {
|
||||
result: message
|
||||
}
|
||||
}
|
||||
// const out = "--" + timestamp + "--\n" + messageContent + " " + (stack || "");
|
||||
// const out
|
||||
try {
|
||||
await plugin.vaultAccess.adapterAppend(plugin.app.vault.configDir + "/ls-debug/" + outFile, JSON.stringify(out) + "\n")
|
||||
} catch (ex) {
|
||||
//NO OP
|
||||
}
|
||||
}));
|
||||
}
|
||||
70
src/tests/tests.ts
Normal file
70
src/tests/tests.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Trench } from "../lib/src/memory/memutil.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
type MeasureResult = [times: number, spent: number];
|
||||
type NamedMeasureResult = [name: string, result: MeasureResult];
|
||||
const measures = new Map<string, MeasureResult>();
|
||||
|
||||
function clearResult(name: string) {
|
||||
measures.set(name, [0, 0]);
|
||||
}
|
||||
async function measureEach(name: string, proc: () => (void | Promise<void>)) {
|
||||
const [times, spent] = measures.get(name) ?? [0, 0];
|
||||
|
||||
const start = performance.now();
|
||||
const result = proc();
|
||||
if (result instanceof Promise) await result;
|
||||
const end = performance.now();
|
||||
measures.set(name, [times + 1, spent + (end - start)]);
|
||||
|
||||
}
|
||||
function formatNumber(num: number) {
|
||||
return num.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
}
|
||||
async function measure(name: string, proc: () => (void | Promise<void>), times: number = 10000, duration: number = 1000): Promise<NamedMeasureResult> {
|
||||
const from = Date.now();
|
||||
let last = times;
|
||||
clearResult(name);
|
||||
do {
|
||||
await measureEach(name, proc);
|
||||
} while (last-- > 0 && (Date.now() - from) < duration)
|
||||
return [name, measures.get(name) as MeasureResult];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
async function formatPerfResults(items: NamedMeasureResult[]) {
|
||||
return `| Name | Runs | Each | Total |\n| --- | --- | --- | --- | \n` + items.map(e => `| ${e[0]} | ${e[1][0]} | ${e[1][0] != 0 ? formatNumber(e[1][1] / e[1][0]) : "-"} | ${formatNumber(e[1][0])} |`).join("\n");
|
||||
}
|
||||
export async function perf_trench(plugin: ObsidianLiveSyncPlugin) {
|
||||
clearResult("trench");
|
||||
const trench = new Trench(plugin.simpleStore);
|
||||
const result = [] as NamedMeasureResult[];
|
||||
result.push(await measure("trench-short-string", async () => {
|
||||
const p = trench.evacuate("string");
|
||||
await p();
|
||||
}));
|
||||
{
|
||||
const testBinary = await plugin.vaultAccess.adapterReadBinary("testdata/10kb.png");
|
||||
const uint8Array = new Uint8Array(testBinary);
|
||||
result.push(await measure("trench-binary-10kb", async () => {
|
||||
const p = trench.evacuate(uint8Array);
|
||||
await p();
|
||||
}));
|
||||
}
|
||||
{
|
||||
const testBinary = await plugin.vaultAccess.adapterReadBinary("testdata/100kb.jpeg");
|
||||
const uint8Array = new Uint8Array(testBinary);
|
||||
result.push(await measure("trench-binary-100kb", async () => {
|
||||
const p = trench.evacuate(uint8Array);
|
||||
await p();
|
||||
}));
|
||||
}
|
||||
{
|
||||
const testBinary = await plugin.vaultAccess.adapterReadBinary("testdata/1mb.png");
|
||||
const uint8Array = new Uint8Array(testBinary);
|
||||
result.push(await measure("trench-binary-1mb", async () => {
|
||||
const p = trench.evacuate(uint8Array);
|
||||
await p();
|
||||
}));
|
||||
}
|
||||
return formatPerfResults(result);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { App, Modal } from "./deps";
|
||||
import { App, Modal } from "../deps.ts";
|
||||
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
||||
import { CANCELLED, LEAVE_TO_SUBSEQUENT, RESULT_TIMED_OUT, type diff_result } from "./lib/src/types";
|
||||
import { escapeStringToHTML } from "./lib/src/strbin";
|
||||
import { delay, sendValue, waitForValue } from "./lib/src/utils";
|
||||
import { CANCELLED, LEAVE_TO_SUBSEQUENT, RESULT_TIMED_OUT, type diff_result } from "../lib/src/common/types.ts";
|
||||
import { escapeStringToHTML } from "../lib/src/string_and_binary/convert.ts";
|
||||
import { delay, sendValue, waitForValue } from "../lib/src/common/utils.ts";
|
||||
|
||||
export type MergeDialogResult = typeof LEAVE_TO_SUBSEQUENT | typeof CANCELLED | string;
|
||||
export class ConflictResolveModal extends Modal {
|
||||
@@ -13,10 +13,22 @@ export class ConflictResolveModal extends Modal {
|
||||
isClosed = false;
|
||||
consumed = false;
|
||||
|
||||
constructor(app: App, filename: string, diff: diff_result) {
|
||||
title: string = "Conflicting changes";
|
||||
|
||||
pluginPickMode: boolean = false;
|
||||
localName: string = "Keep A";
|
||||
remoteName: string = "Keep B";
|
||||
|
||||
constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string) {
|
||||
super(app);
|
||||
this.result = diff;
|
||||
this.filename = filename;
|
||||
this.pluginPickMode = pluginPickMode || false;
|
||||
if (this.pluginPickMode) {
|
||||
this.title = "Pick a version";
|
||||
this.remoteName = `Use ${remoteName || "Remote"}`;
|
||||
this.localName = "Use Local"
|
||||
}
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
// if not there, simply be ignored.
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
@@ -36,7 +48,7 @@ export class ConflictResolveModal extends Modal {
|
||||
}
|
||||
}, 10)
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
this.titleEl.setText("Conflicting changes");
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
contentEl.createEl("span", { text: this.filename });
|
||||
const div = contentEl.createDiv("");
|
||||
@@ -62,10 +74,12 @@ export class ConflictResolveModal extends Modal {
|
||||
div2.innerHTML = `
|
||||
<span class='deleted'>A:${date1}</span><br /><span class='added'>B:${date2}</span><br>
|
||||
`;
|
||||
contentEl.createEl("button", { text: "Keep A" }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.right.rev)));
|
||||
contentEl.createEl("button", { text: "Keep B" }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.left.rev)));
|
||||
contentEl.createEl("button", { text: "Concat both" }, (e) => e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT)));
|
||||
contentEl.createEl("button", { text: "Not now" }, (e) => e.addEventListener("click", () => this.sendResponse(CANCELLED)));
|
||||
contentEl.createEl("button", { text: this.localName }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.right.rev))).style.marginRight = "4px";
|
||||
contentEl.createEl("button", { text: this.remoteName }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.left.rev))).style.marginRight = "4px";
|
||||
if (!this.pluginPickMode) {
|
||||
contentEl.createEl("button", { text: "Concat both" }, (e) => e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT))).style.marginRight = "4px";
|
||||
}
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => e.addEventListener("click", () => this.sendResponse(CANCELLED))).style.marginRight = "4px";
|
||||
}
|
||||
|
||||
sendResponse(result: MergeDialogResult) {
|
||||
@@ -1,12 +1,12 @@
|
||||
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "./deps";
|
||||
import { getPathFromTFile, isValidPath } from "./utils";
|
||||
import { decodeBinary, escapeStringToHTML, readString } from "./lib/src/strbin";
|
||||
import ObsidianLiveSyncPlugin from "./main";
|
||||
import { type DocumentID, type FilePathWithPrefix, type LoadedEntry, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "./lib/src/types";
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import { isErrorOfMissingDoc } from "./lib/src/utils_couchdb";
|
||||
import { getDocData } from "./lib/src/utils";
|
||||
import { isPlainText, stripPrefix } from "./lib/src/path";
|
||||
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../deps.ts";
|
||||
import { getPathFromTFile, isValidPath } from "../common/utils.ts";
|
||||
import { decodeBinary, escapeStringToHTML, readString } from "../lib/src/string_and_binary/convert.ts";
|
||||
import ObsidianLiveSyncPlugin from "../main.ts";
|
||||
import { type DocumentID, type FilePathWithPrefix, type LoadedEntry, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../lib/src/common/types.ts";
|
||||
import { Logger } from "../lib/src/common/logger.ts";
|
||||
import { isErrorOfMissingDoc } from "../lib/src/pouchdb/utils_couchdb.ts";
|
||||
import { getDocData, readContent } from "../lib/src/common/utils.ts";
|
||||
import { isPlainText, stripPrefix } from "../lib/src/string_and_binary/path.ts";
|
||||
|
||||
function isImage(path: string) {
|
||||
const ext = path.split(".").splice(-1)[0].toLowerCase();
|
||||
@@ -25,7 +25,7 @@ function readDocument(w: LoadedEntry) {
|
||||
if (isImage(w.path)) {
|
||||
return new Uint8Array(decodeBinary(w.data));
|
||||
}
|
||||
if (w.data == "plain") return getDocData(w.data);
|
||||
if (w.type == "plain" || w.datatype == "plain") return getDocData(w.data);
|
||||
if (isComparableTextDecode(w.path)) return readString(new Uint8Array(decodeBinary(w.data)));
|
||||
if (isComparableText(w.path)) return getDocData(w.data);
|
||||
try {
|
||||
@@ -66,7 +66,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
async loadFile(initialRev: string) {
|
||||
async loadFile(initialRev?: string) {
|
||||
if (!this.id) {
|
||||
this.id = await this.plugin.path2id(this.file);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (v) {
|
||||
URL.revokeObjectURL(v);
|
||||
}
|
||||
this.BlobURLs.set(key, undefined);
|
||||
this.BlobURLs.delete(key);
|
||||
}
|
||||
generateBlobURL(key: string, data: Uint8Array) {
|
||||
this.revokeURL(key);
|
||||
@@ -247,12 +247,10 @@ export class DocumentHistoryModal extends Modal {
|
||||
Logger(`Old content copied to clipboard`, LOG_LEVEL_NOTICE);
|
||||
});
|
||||
});
|
||||
async function focusFile(path: string) {
|
||||
const targetFile = app.vault
|
||||
.getFiles()
|
||||
.find((f) => f.path === path);
|
||||
const focusFile = async (path: string) => {
|
||||
const targetFile = this.plugin.app.vault.getFileByPath(path);
|
||||
if (targetFile) {
|
||||
const leaf = app.workspace.getLeaf(false);
|
||||
const leaf = this.plugin.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(targetFile);
|
||||
} else {
|
||||
Logger("The file could not view on the editor", LOG_LEVEL_NOTICE)
|
||||
@@ -265,19 +263,16 @@ export class DocumentHistoryModal extends Modal {
|
||||
const pathToWrite = stripPrefix(this.file);
|
||||
if (!isValidPath(pathToWrite)) {
|
||||
Logger("Path is not valid to write content.", LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
if (this.currentDoc?.datatype == "plain") {
|
||||
await this.plugin.vaultAccess.adapterWrite(pathToWrite, getDocData(this.currentDoc.data));
|
||||
await focusFile(pathToWrite);
|
||||
this.close();
|
||||
} else if (this.currentDoc?.datatype == "newnote") {
|
||||
await this.plugin.vaultAccess.adapterWrite(pathToWrite, decodeBinary(this.currentDoc.data));
|
||||
await focusFile(pathToWrite);
|
||||
this.close();
|
||||
} else {
|
||||
|
||||
Logger(`Could not parse entry`, LOG_LEVEL_NOTICE);
|
||||
if (!this.currentDoc) {
|
||||
Logger("No active file loaded.", LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
const d = readContent(this.currentDoc);
|
||||
await this.plugin.vaultAccess.adapterWrite(pathToWrite, d);
|
||||
await focusFile(pathToWrite);
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ObsidianLiveSyncPlugin from "./main";
|
||||
import ObsidianLiveSyncPlugin from "../main";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import type { AnyEntry, FilePathWithPrefix } from "./lib/src/types";
|
||||
import { createBinaryBlob, getDocData, isDocContentSame } from "./lib/src/utils";
|
||||
import { diff_match_patch } from "./deps";
|
||||
import type { AnyEntry, FilePathWithPrefix } from "../lib/src/common/types";
|
||||
import { getDocData, isAnyNote, isDocContentSame, readAsBlob } from "../lib/src/common/utils";
|
||||
import { diff_match_patch } from "../deps";
|
||||
import { DocumentHistoryModal } from "./DocumentHistoryModal";
|
||||
import { isPlainText, stripAllPrefixes } from "./lib/src/path";
|
||||
import { TFile } from "./deps";
|
||||
import { decodeBinary } from "./lib/src/strbin";
|
||||
import { isPlainText, stripAllPrefixes } from "../lib/src/string_and_binary/path";
|
||||
import { TFile } from "../deps";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
|
||||
let showDiffInfo = false;
|
||||
@@ -31,7 +30,7 @@
|
||||
|
||||
type HistoryData = {
|
||||
id: string;
|
||||
rev: string;
|
||||
rev?: string;
|
||||
path: string;
|
||||
dirname: string;
|
||||
filename: string;
|
||||
@@ -54,12 +53,12 @@
|
||||
if (docA.mtime < range_from_epoch) {
|
||||
continue;
|
||||
}
|
||||
if (docA.type != "newnote" && docA.type != "plain") continue;
|
||||
if (!isAnyNote(docA)) continue;
|
||||
const path = plugin.getPath(docA as AnyEntry);
|
||||
const isPlain = isPlainText(docA.path);
|
||||
const revs = await db.getRaw(docA._id, { revs_info: true });
|
||||
let p: string = undefined;
|
||||
const reversedRevs = revs._revs_info.reverse();
|
||||
let p: string | undefined = undefined;
|
||||
const reversedRevs = (revs._revs_info ?? []).reverse();
|
||||
const DIFF_DELETE = -1;
|
||||
|
||||
const DIFF_EQUAL = 0;
|
||||
@@ -107,15 +106,9 @@
|
||||
if (checkStorageDiff) {
|
||||
const abs = plugin.vaultAccess.getAbstractFileByPath(stripAllPrefixes(plugin.getPath(docA)));
|
||||
if (abs instanceof TFile) {
|
||||
let result = false;
|
||||
if (isPlainText(docA.path)) {
|
||||
const data = await plugin.vaultAccess.adapterRead(abs);
|
||||
result = await isDocContentSame(data, doc.data);
|
||||
} else {
|
||||
const data = await plugin.vaultAccess.adapterReadBinary(abs);
|
||||
const dataEEncoded = createBinaryBlob(data);
|
||||
result = await isDocContentSame(dataEEncoded, createBinaryBlob(decodeBinary(doc.data)));
|
||||
}
|
||||
const data = await plugin.vaultAccess.adapterReadAuto(abs);
|
||||
const d = readAsBlob(doc);
|
||||
const result = await isDocContentSame(data, d);
|
||||
if (result) {
|
||||
diffDetail += " ⚖️";
|
||||
} else {
|
||||
@@ -184,7 +177,7 @@
|
||||
onDestroy(() => {});
|
||||
|
||||
function showHistory(file: string, rev: string) {
|
||||
new DocumentHistoryModal(plugin.app, plugin, file as unknown as FilePathWithPrefix, null, rev).open();
|
||||
new DocumentHistoryModal(plugin.app, plugin, file as unknown as FilePathWithPrefix, undefined, rev).open();
|
||||
}
|
||||
function openFile(file: string) {
|
||||
plugin.app.workspace.openLinkText(file, file);
|
||||
@@ -233,13 +226,19 @@
|
||||
<td class="path">
|
||||
<div class="filenames">
|
||||
<span class="path">/{entry.dirname.split("/").join(`/`)}</span>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<span class="filename"><a on:click={() => openFile(entry.path)}>{entry.filename}</a></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="rev">
|
||||
{#if entry.isPlain}
|
||||
<a on:click={() => showHistory(entry.path, entry.rev)}>{entry.rev}</a>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<a on:click={() => showHistory(entry.path, entry?.rev || "")}>{entry.rev}</a>
|
||||
{:else}
|
||||
{entry.rev}
|
||||
{/if}
|
||||
@@ -1,18 +1,18 @@
|
||||
import {
|
||||
ItemView,
|
||||
WorkspaceLeaf
|
||||
} from "./deps";
|
||||
} from "../deps.ts";
|
||||
import GlobalHistoryComponent from "./GlobalHistory.svelte";
|
||||
import type ObsidianLiveSyncPlugin from "./main";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
|
||||
export const VIEW_TYPE_GLOBAL_HISTORY = "global-history";
|
||||
export class GlobalHistoryView extends ItemView {
|
||||
|
||||
component: GlobalHistoryComponent;
|
||||
component?: GlobalHistoryComponent;
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon: "clock";
|
||||
title: string;
|
||||
navigation: true;
|
||||
icon = "clock";
|
||||
title: string = "";
|
||||
navigation = true;
|
||||
|
||||
getIcon(): string {
|
||||
return "clock";
|
||||
@@ -44,6 +44,6 @@ export class GlobalHistoryView extends ItemView {
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
async onClose() {
|
||||
this.component.$destroy();
|
||||
this.component?.$destroy();
|
||||
}
|
||||
}
|
||||
77
src/ui/JsonResolveModal.ts
Normal file
77
src/ui/JsonResolveModal.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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";
|
||||
|
||||
export class JsonResolveModal extends Modal {
|
||||
// result: Array<[number, string]>;
|
||||
filename: FilePath;
|
||||
callback?: (keepRev?: string, mergedStr?: string) => Promise<void>;
|
||||
docs: LoadedEntry[];
|
||||
component?: JsonResolvePane;
|
||||
nameA: string;
|
||||
nameB: string;
|
||||
defaultSelect: string;
|
||||
keepOrder: boolean;
|
||||
hideLocal: boolean;
|
||||
title: string = "Conflicted Setting";
|
||||
|
||||
constructor(app: App, filename: FilePath,
|
||||
docs: LoadedEntry[], callback: (keepRev?: string, mergedStr?: string) => Promise<void>,
|
||||
nameA?: string, nameB?: string, defaultSelect?: string,
|
||||
keepOrder?: boolean, hideLocal?: boolean, title: string = "Conflicted Setting") {
|
||||
super(app);
|
||||
this.callback = callback;
|
||||
this.filename = filename;
|
||||
this.docs = docs;
|
||||
this.nameA = nameA || "";
|
||||
this.nameB = nameB || "";
|
||||
this.keepOrder = keepOrder || false;
|
||||
this.defaultSelect = defaultSelect || "";
|
||||
this.title = title;
|
||||
this.hideLocal = hideLocal ?? false;
|
||||
waitForSignal(`cancel-internal-conflict:${filename}`).then(() => this.close());
|
||||
}
|
||||
async UICallback(keepRev?: string, mergedStr?: string) {
|
||||
this.close();
|
||||
await this.callback?.(keepRev, mergedStr);
|
||||
this.callback = undefined;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
|
||||
if (this.component == undefined) {
|
||||
this.component = new JsonResolvePane({
|
||||
target: contentEl,
|
||||
props: {
|
||||
docs: this.docs,
|
||||
filename: this.filename,
|
||||
nameA: this.nameA,
|
||||
nameB: this.nameB,
|
||||
defaultSelect: this.defaultSelect,
|
||||
keepOrder: this.keepOrder,
|
||||
hideLocal: this.hideLocal,
|
||||
callback: (keepRev: string | undefined, mergedStr: string | undefined) => this.UICallback(keepRev, mergedStr),
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
// contentEl.empty();
|
||||
if (this.callback != undefined) {
|
||||
this.callback(undefined);
|
||||
}
|
||||
if (this.component != undefined) {
|
||||
this.component.$destroy();
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { type Diff, DIFF_DELETE, DIFF_INSERT, diff_match_patch } from "./deps";
|
||||
import type { FilePath, LoadedEntry } from "./lib/src/types";
|
||||
import { decodeBinary, readString } from "./lib/src/strbin";
|
||||
import { getDocData } from "./lib/src/utils";
|
||||
import { mergeObject } from "./utils";
|
||||
import { type Diff, DIFF_DELETE, DIFF_INSERT, diff_match_patch } from "../deps";
|
||||
import type { FilePath, LoadedEntry } from "../lib/src/common/types";
|
||||
import { decodeBinary, readString } from "../lib/src/string_and_binary/convert";
|
||||
import { getDocData } from "../lib/src/common/utils";
|
||||
import { mergeObject } from "../common/utils";
|
||||
|
||||
export let docs: LoadedEntry[] = [];
|
||||
export let callback: (keepRev: string, mergedStr?: string) => Promise<void> = async (_, __) => {
|
||||
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 = "";
|
||||
let docA: LoadedEntry = undefined;
|
||||
let docB: LoadedEntry = undefined;
|
||||
export let keepOrder = false;
|
||||
export let hideLocal: boolean = false;
|
||||
let docA: LoadedEntry;
|
||||
let docB: LoadedEntry;
|
||||
let docAContent = "";
|
||||
let docBContent = "";
|
||||
let objA: any = {};
|
||||
@@ -28,7 +30,8 @@
|
||||
function docToString(doc: LoadedEntry) {
|
||||
return doc.datatype == "plain" ? getDocData(doc.data) : readString(new Uint8Array(decodeBinary(doc.data)));
|
||||
}
|
||||
function revStringToRevNumber(rev: string) {
|
||||
function revStringToRevNumber(rev?: string) {
|
||||
if (!rev) return "";
|
||||
return rev.split("-")[0];
|
||||
}
|
||||
|
||||
@@ -44,19 +47,22 @@
|
||||
}
|
||||
function apply() {
|
||||
if (docA._id == docB._id) {
|
||||
if (mode == "A") return callback(docA._rev, null);
|
||||
if (mode == "B") return callback(docB._rev, null);
|
||||
if (mode == "A") return callback(docA._rev!, undefined);
|
||||
if (mode == "B") return callback(docB._rev!, undefined);
|
||||
} else {
|
||||
if (mode == "A") return callback(null, docToString(docA));
|
||||
if (mode == "B") return callback(null, docToString(docB));
|
||||
if (mode == "A") return callback(undefined, docToString(docA));
|
||||
if (mode == "B") return callback(undefined, docToString(docB));
|
||||
}
|
||||
if (mode == "BA") return callback(null, JSON.stringify(objBA, null, 2));
|
||||
if (mode == "AB") return callback(null, JSON.stringify(objAB, null, 2));
|
||||
callback(null, null);
|
||||
if (mode == "BA") return callback(undefined, JSON.stringify(objBA, null, 2));
|
||||
if (mode == "AB") return callback(undefined, JSON.stringify(objAB, null, 2));
|
||||
callback(undefined, undefined);
|
||||
}
|
||||
function cancel() {
|
||||
callback(undefined, undefined);
|
||||
}
|
||||
$: {
|
||||
if (docs && docs.length >= 1) {
|
||||
if (docs[0].mtime < docs[1].mtime) {
|
||||
if (keepOrder || docs[0].mtime < docs[1].mtime) {
|
||||
docA = docs[0];
|
||||
docB = docs[1];
|
||||
} else {
|
||||
@@ -95,13 +101,19 @@
|
||||
diffs = getJsonDiff(objA, selectedObj);
|
||||
}
|
||||
|
||||
$: modes = [
|
||||
["", "Not now"],
|
||||
["A", nameA || "A"],
|
||||
["B", nameB || "B"],
|
||||
["AB", `${nameA || "A"} + ${nameB || "B"}`],
|
||||
["BA", `${nameB || "B"} + ${nameA || "A"}`],
|
||||
] as ["" | "A" | "B" | "AB" | "BA", string][];
|
||||
let modes = [] as ["" | "A" | "B" | "AB" | "BA", string][];
|
||||
$: {
|
||||
let newModes = [] as typeof modes;
|
||||
|
||||
if (!hideLocal) {
|
||||
newModes.push(["", "Not now"]);
|
||||
newModes.push(["A", nameA || "A"]);
|
||||
}
|
||||
newModes.push(["B", nameB || "B"]);
|
||||
newModes.push(["AB", `${nameA || "A"} + ${nameB || "B"}`]);
|
||||
newModes.push(["BA", `${nameB || "B"} + ${nameA || "A"}`]);
|
||||
modes = newModes;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2>{filename}</h2>
|
||||
@@ -131,24 +143,54 @@
|
||||
{:else}
|
||||
NO PREVIEW
|
||||
{/if}
|
||||
<div>
|
||||
{nameA}
|
||||
{#if docA._id == docB._id} Rev:{revStringToRevNumber(docA._rev)} {/if} ,{new Date(docA.mtime).toLocaleString()}
|
||||
{docAContent.length} letters
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{nameB}
|
||||
{#if docA._id == docB._id} Rev:{revStringToRevNumber(docB._rev)} {/if} ,{new Date(docB.mtime).toLocaleString()}
|
||||
{docBContent.length} letters
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
{#if hideLocal}
|
||||
<button on:click={cancel}>Cancel</button>
|
||||
{/if}
|
||||
<button on:click={apply}>Apply</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.infos {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 4px 0.5em;
|
||||
}
|
||||
|
||||
.deleted {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { logMessages } from "./lib/src/stores";
|
||||
import type { ReactiveInstance } from "./lib/src/reactive";
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import { logMessages } from "../lib/src/mock_and_interop/stores";
|
||||
import type { ReactiveInstance } from "../lib/src/dataobject/reactive";
|
||||
import { Logger } from "../lib/src/common/logger";
|
||||
|
||||
let unsubscribe: () => void;
|
||||
let messages = [] as string[];
|
||||
@@ -3,16 +3,16 @@ import {
|
||||
WorkspaceLeaf
|
||||
} from "obsidian";
|
||||
import LogPaneComponent from "./LogPane.svelte";
|
||||
import type ObsidianLiveSyncPlugin from "./main";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
export const VIEW_TYPE_LOG = "log-log";
|
||||
//Log view
|
||||
export class LogPaneView extends ItemView {
|
||||
|
||||
component: LogPaneComponent;
|
||||
component?: LogPaneComponent;
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon: "view-log";
|
||||
title: string;
|
||||
navigation: true;
|
||||
icon = "view-log";
|
||||
title: string = "";
|
||||
navigation = true;
|
||||
|
||||
getIcon(): string {
|
||||
return "view-log";
|
||||
@@ -43,6 +43,6 @@ export class LogPaneView extends ItemView {
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
async onClose() {
|
||||
this.component.$destroy();
|
||||
this.component?.$destroy();
|
||||
}
|
||||
}
|
||||
2534
src/ui/ObsidianLiveSyncSettingTab.ts
Normal file
2534
src/ui/ObsidianLiveSyncSettingTab.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import ObsidianLiveSyncPlugin from "./main";
|
||||
import { type PluginDataExDisplay, pluginIsEnumerating, pluginList } from "./CmdConfigSync";
|
||||
import PluginCombo from "./PluginCombo.svelte";
|
||||
import { Menu } from "obsidian";
|
||||
import { unique } from "./lib/src/utils";
|
||||
import { MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, type SYNC_MODE, type PluginSyncSettingEntry } from "./lib/src/types";
|
||||
import { normalizePath } from "./deps";
|
||||
import ObsidianLiveSyncPlugin from "../main";
|
||||
import { type IPluginDataExDisplay, pluginIsEnumerating, pluginList, pluginManifestStore, pluginV2Progress } from "../features/CmdConfigSync";
|
||||
import PluginCombo from "./components/PluginCombo.svelte";
|
||||
import { Menu, type PluginManifest } from "obsidian";
|
||||
import { unique } from "../lib/src/common/utils";
|
||||
import { MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, type SYNC_MODE, type PluginSyncSettingEntry, MODE_SHINY } from "../lib/src/common/types";
|
||||
import { normalizePath } from "../deps";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
|
||||
$: hideNotApplicable = false;
|
||||
@@ -14,9 +14,10 @@
|
||||
|
||||
const addOn = plugin.addOnConfigSync;
|
||||
|
||||
let list: PluginDataExDisplay[] = [];
|
||||
let list: IPluginDataExDisplay[] = [];
|
||||
|
||||
let selectNewestPulse = 0;
|
||||
let selectNewestStyle = 0;
|
||||
let hideEven = false;
|
||||
let loading = false;
|
||||
let applyAllPluse = 0;
|
||||
@@ -39,13 +40,13 @@
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
function filterList(list: PluginDataExDisplay[], categories: string[]) {
|
||||
function filterList(list: IPluginDataExDisplay[], categories: string[]) {
|
||||
const w = list.filter((e) => categories.indexOf(e.category) !== -1);
|
||||
return w.sort((a, b) => `${a.category}-${a.name}`.localeCompare(`${b.category}-${b.name}`));
|
||||
}
|
||||
|
||||
function groupBy(items: PluginDataExDisplay[], key: string) {
|
||||
let ret = {} as Record<string, PluginDataExDisplay[]>;
|
||||
function groupBy(items: IPluginDataExDisplay[], key: string) {
|
||||
let ret = {} as Record<string, IPluginDataExDisplay[]>;
|
||||
for (const v of items) {
|
||||
//@ts-ignore
|
||||
const k = (key in v ? v[key] : "") as string;
|
||||
@@ -71,19 +72,24 @@
|
||||
async function replicate() {
|
||||
await plugin.replicate(true);
|
||||
}
|
||||
function selectAllNewest() {
|
||||
function selectAllNewest(selectMode: boolean) {
|
||||
selectNewestPulse++;
|
||||
selectNewestStyle = selectMode ? 1 : 2;
|
||||
}
|
||||
function resetSelectNewest() {
|
||||
selectNewestPulse++;
|
||||
selectNewestStyle = 3;
|
||||
}
|
||||
function applyAll() {
|
||||
applyAllPluse++;
|
||||
}
|
||||
async function applyData(data: PluginDataExDisplay): Promise<boolean> {
|
||||
async function applyData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.applyData(data);
|
||||
}
|
||||
async function compareData(docA: PluginDataExDisplay, docB: PluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.compareUsingDisplayData(docA, docB);
|
||||
async function compareData(docA: IPluginDataExDisplay, docB: IPluginDataExDisplay, compareEach = false): Promise<boolean> {
|
||||
return await addOn.compareUsingDisplayData(docA, docB, compareEach);
|
||||
}
|
||||
async function deleteData(data: PluginDataExDisplay): Promise<boolean> {
|
||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.deleteData(data);
|
||||
}
|
||||
function askMode(evt: MouseEvent, title: string, key: string) {
|
||||
@@ -91,7 +97,7 @@
|
||||
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
|
||||
menu.addSeparator();
|
||||
const prevMode = automaticList.get(key) ?? MODE_SELECTIVE;
|
||||
for (const mode of [MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED]) {
|
||||
for (const mode of [MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, MODE_SHINY]) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
|
||||
.onClick((e) => {
|
||||
@@ -139,6 +145,7 @@
|
||||
thisTerm,
|
||||
hideNotApplicable,
|
||||
selectNewest: selectNewestPulse,
|
||||
selectNewestStyle,
|
||||
applyAllPluse,
|
||||
applyData,
|
||||
compareData,
|
||||
@@ -150,24 +157,29 @@
|
||||
const ICON_EMOJI_PAUSED = `⛔`;
|
||||
const ICON_EMOJI_AUTOMATIC = `✨`;
|
||||
const ICON_EMOJI_SELECTIVE = `🔀`;
|
||||
const ICON_EMOJI_FLAGGED = `🚩`;
|
||||
|
||||
const ICONS: { [key: number]: string } = {
|
||||
[MODE_SELECTIVE]: ICON_EMOJI_SELECTIVE,
|
||||
[MODE_PAUSED]: ICON_EMOJI_PAUSED,
|
||||
[MODE_AUTOMATIC]: ICON_EMOJI_AUTOMATIC,
|
||||
[MODE_SHINY]: ICON_EMOJI_FLAGGED,
|
||||
};
|
||||
const TITLES: { [key: number]: string } = {
|
||||
[MODE_SELECTIVE]: "Selective",
|
||||
[MODE_PAUSED]: "Ignore",
|
||||
[MODE_AUTOMATIC]: "Automatic",
|
||||
[MODE_SHINY]: "Flagged Selective",
|
||||
};
|
||||
const PREFIX_PLUGIN_ALL = "PLUGIN_ALL";
|
||||
const PREFIX_PLUGIN_DATA = "PLUGIN_DATA";
|
||||
const PREFIX_PLUGIN_MAIN = "PLUGIN_MAIN";
|
||||
const PREFIX_PLUGIN_ETC = "PLUGIN_ETC";
|
||||
function setMode(key: string, mode: SYNC_MODE) {
|
||||
if (key.startsWith(PREFIX_PLUGIN_ALL + "/")) {
|
||||
setMode(PREFIX_PLUGIN_DATA + key.substring(PREFIX_PLUGIN_ALL.length), mode);
|
||||
setMode(PREFIX_PLUGIN_MAIN + key.substring(PREFIX_PLUGIN_ALL.length), mode);
|
||||
return;
|
||||
}
|
||||
const files = unique(
|
||||
list
|
||||
@@ -176,17 +188,23 @@
|
||||
.flat()
|
||||
.map((e) => e.filename),
|
||||
);
|
||||
automaticList.set(key, mode);
|
||||
automaticListDisp = automaticList;
|
||||
if (!(key in plugin.settings.pluginSyncExtendedSetting)) {
|
||||
plugin.settings.pluginSyncExtendedSetting[key] = {
|
||||
key,
|
||||
mode,
|
||||
files: [],
|
||||
};
|
||||
if (mode == MODE_SELECTIVE) {
|
||||
automaticList.delete(key);
|
||||
delete plugin.settings.pluginSyncExtendedSetting[key];
|
||||
automaticListDisp = automaticList;
|
||||
} else {
|
||||
automaticList.set(key, mode);
|
||||
automaticListDisp = automaticList;
|
||||
if (!(key in plugin.settings.pluginSyncExtendedSetting)) {
|
||||
plugin.settings.pluginSyncExtendedSetting[key] = {
|
||||
key,
|
||||
mode,
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
plugin.settings.pluginSyncExtendedSetting[key].files = files;
|
||||
plugin.settings.pluginSyncExtendedSetting[key].mode = mode;
|
||||
}
|
||||
plugin.settings.pluginSyncExtendedSetting[key].files = files;
|
||||
plugin.settings.pluginSyncExtendedSetting[key].mode = mode;
|
||||
plugin.saveSettingData();
|
||||
}
|
||||
function getIcon(mode: SYNC_MODE) {
|
||||
@@ -208,9 +226,9 @@
|
||||
|
||||
let displayKeys: Record<string, string[]> = {};
|
||||
|
||||
$: {
|
||||
function computeDisplayKeys(list: IPluginDataExDisplay[]) {
|
||||
const extraKeys = Object.keys(plugin.settings.pluginSyncExtendedSetting);
|
||||
displayKeys = [
|
||||
return [
|
||||
...list,
|
||||
...extraKeys
|
||||
.map((e) => `${e}///`.split("/"))
|
||||
@@ -220,6 +238,9 @@
|
||||
.sort((a, b) => (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name))
|
||||
.reduce((p, c) => ({ ...p, [c.category]: unique(c.category in p ? [...p[c.category], c.displayName ?? c.name] : [c.displayName ?? c.name]) }), {} as Record<string, string[]>);
|
||||
}
|
||||
$: {
|
||||
displayKeys = computeDisplayKeys(list);
|
||||
}
|
||||
|
||||
let deleteTerm = "";
|
||||
|
||||
@@ -230,145 +251,202 @@
|
||||
}
|
||||
addOn.reloadPluginList(true);
|
||||
}
|
||||
|
||||
let nameMap = new Map<string, string>();
|
||||
function updateNameMap(e: Map<string, PluginManifest>) {
|
||||
const items = [...e.entries()].map(([k, v]) => [k.split("/").slice(-2).join("/"), v.name] as [string, string]);
|
||||
const newMap = new Map(items);
|
||||
if (newMap.size == nameMap.size) {
|
||||
let diff = false;
|
||||
for (const [k, v] of newMap) {
|
||||
if (nameMap.get(k) != v) {
|
||||
diff = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!diff) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
nameMap = newMap;
|
||||
}
|
||||
$: updateNameMap($pluginManifestStore);
|
||||
|
||||
let displayEntries = [] as [string, string][];
|
||||
$: {
|
||||
displayEntries = Object.entries(displays).filter(([key, _]) => key in displayKeys);
|
||||
}
|
||||
|
||||
let pluginEntries = [] as [string, IPluginDataExDisplay[]][];
|
||||
$: {
|
||||
pluginEntries = groupBy(filterList(list, ["PLUGIN_MAIN", "PLUGIN_DATA", "PLUGIN_ETC"]), "name");
|
||||
}
|
||||
let useSyncPluginEtc = plugin.settings.usePluginEtc;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<div class="buttons">
|
||||
<button on:click={() => scanAgain()}>Scan changes</button>
|
||||
<button on:click={() => replicate()}>Sync once</button>
|
||||
<button on:click={() => requestUpdate()}>Refresh</button>
|
||||
{#if isMaintenanceMode}
|
||||
<button on:click={() => requestReload()}>Reload</button>
|
||||
{/if}
|
||||
<button on:click={() => selectAllNewest()}>Select All Shiny</button>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button on:click={() => applyAll()}>Apply All</button>
|
||||
</div>
|
||||
<div class="buttonsWrap">
|
||||
<div class="buttons">
|
||||
<button on:click={() => scanAgain()}>Scan changes</button>
|
||||
<button on:click={() => replicate()}>Sync once</button>
|
||||
<button on:click={() => requestUpdate()}>Refresh</button>
|
||||
{#if isMaintenanceMode}
|
||||
<button on:click={() => requestReload()}>Reload</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if loading}
|
||||
<div>
|
||||
<span>Updating list...</span>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button on:click={() => selectAllNewest(true)}>Select All Shiny</button>
|
||||
<button on:click={() => selectAllNewest(false)}>{ICON_EMOJI_FLAGGED} Select Flagged Shiny</button>
|
||||
<button on:click={() => resetSelectNewest()}>Deselect all</button>
|
||||
<button on:click={() => applyAll()} class="mod-cta">Apply All Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading">
|
||||
{#if loading || $pluginV2Progress !== 0}
|
||||
<span>Updating list...{$pluginV2Progress == 0 ? "" : ` (${$pluginV2Progress})`}</span>
|
||||
{/if}
|
||||
<div class="list">
|
||||
{#if list.length == 0}
|
||||
<div class="center">No Items.</div>
|
||||
{:else}
|
||||
{#each Object.entries(displays).filter(([key, _]) => key in displayKeys) as [key, label]}
|
||||
<div>
|
||||
<h3>{label}</h3>
|
||||
{#each displayKeys[key] as name}
|
||||
{@const bindKey = `${key}/${name}`}
|
||||
{@const mode = automaticListDisp.get(bindKey) ?? MODE_SELECTIVE}
|
||||
<div class="labelrow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="title">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${key}/${name}`, bindKey)}>
|
||||
{getIcon(mode)}
|
||||
</button>
|
||||
<span class="name">{name}</span>
|
||||
</div>
|
||||
{#if mode == MODE_SELECTIVE}
|
||||
<PluginCombo {...options} list={list.filter((e) => e.category == key && e.name == name)} hidden={false} />
|
||||
</div>
|
||||
<div class="list">
|
||||
{#if list.length == 0}
|
||||
<div class="center">No Items.</div>
|
||||
{:else}
|
||||
{#each displayEntries as [key, label]}
|
||||
<div>
|
||||
<h3>{label}</h3>
|
||||
{#each displayKeys[key] as name}
|
||||
{@const bindKey = `${key}/${name}`}
|
||||
{@const mode = automaticListDisp.get(bindKey) ?? MODE_SELECTIVE}
|
||||
<div class="labelrow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="title">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${key}/${name}`, bindKey)}>
|
||||
{getIcon(mode)}
|
||||
</button>
|
||||
<span class="name">{(key == "THEME" && nameMap.get(`themes/${name}`)) || name}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if mode == MODE_SELECTIVE || mode == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={mode == MODE_SHINY} list={list.filter((e) => e.category == key && e.name == name)} hidden={false} />
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[mode]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<div>
|
||||
<h3>Plugins</h3>
|
||||
{#each groupBy(filterList(list, ["PLUGIN_MAIN", "PLUGIN_DATA", "PLUGIN_ETC"]), "name") as [name, listX]}
|
||||
{@const bindKeyAll = `${PREFIX_PLUGIN_ALL}/${name}`}
|
||||
{@const modeAll = automaticListDisp.get(bindKeyAll) ?? MODE_SELECTIVE}
|
||||
{@const bindKeyMain = `${PREFIX_PLUGIN_MAIN}/${name}`}
|
||||
{@const modeMain = automaticListDisp.get(bindKeyMain) ?? MODE_SELECTIVE}
|
||||
{@const bindKeyData = `${PREFIX_PLUGIN_DATA}/${name}`}
|
||||
{@const modeData = automaticListDisp.get(bindKeyData) ?? MODE_SELECTIVE}
|
||||
<div class="labelrow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="title">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ALL}/${name}`, bindKeyAll)}>
|
||||
{getIcon(modeAll)}
|
||||
</button>
|
||||
<span class="name">{name}</span>
|
||||
</div>
|
||||
{#if modeAll == MODE_SELECTIVE}
|
||||
<PluginCombo {...options} list={listX} hidden={true} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<div>
|
||||
<h3>Plugins</h3>
|
||||
{#each pluginEntries as [name, listX]}
|
||||
{@const bindKeyAll = `${PREFIX_PLUGIN_ALL}/${name}`}
|
||||
{@const modeAll = automaticListDisp.get(bindKeyAll) ?? MODE_SELECTIVE}
|
||||
{@const bindKeyMain = `${PREFIX_PLUGIN_MAIN}/${name}`}
|
||||
{@const modeMain = automaticListDisp.get(bindKeyMain) ?? MODE_SELECTIVE}
|
||||
{@const bindKeyData = `${PREFIX_PLUGIN_DATA}/${name}`}
|
||||
{@const modeData = automaticListDisp.get(bindKeyData) ?? MODE_SELECTIVE}
|
||||
{@const bindKeyETC = `${PREFIX_PLUGIN_ETC}/${name}`}
|
||||
{@const modeEtc = automaticListDisp.get(bindKeyETC) ?? MODE_SELECTIVE}
|
||||
<div class="labelrow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="title">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ALL}/${name}`, bindKeyAll)}>
|
||||
{getIcon(modeAll)}
|
||||
</button>
|
||||
<span class="name">{nameMap.get(`plugins/${name}`) || name}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeAll == MODE_SELECTIVE || modeAll == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeAll == MODE_SHINY} list={listX} hidden={true} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if modeAll == MODE_SELECTIVE}
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_MAIN}/${name}/MAIN`, bindKeyMain)}>
|
||||
{getIcon(modeMain)}
|
||||
</button>
|
||||
<span class="name">MAIN</span>
|
||||
</div>
|
||||
{#if modeMain == MODE_SELECTIVE}
|
||||
<PluginCombo {...options} list={filterList(listX, ["PLUGIN_MAIN"])} hidden={false} />
|
||||
</div>
|
||||
{#if modeAll == MODE_SELECTIVE || modeAll == MODE_SHINY}
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_MAIN}/${name}/MAIN`, bindKeyMain)}>
|
||||
{getIcon(modeMain)}
|
||||
</button>
|
||||
<span class="name">MAIN</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeMain == MODE_SELECTIVE || modeMain == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeMain == MODE_SHINY} list={filterList(listX, ["PLUGIN_MAIN"])} hidden={false} />
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[modeMain]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_DATA}/${name}`, bindKeyData)}>
|
||||
{getIcon(modeData)}
|
||||
</button>
|
||||
<span class="name">DATA</span>
|
||||
</div>
|
||||
{#if modeData == MODE_SELECTIVE}
|
||||
<PluginCombo {...options} list={filterList(listX, ["PLUGIN_DATA"])} hidden={false} />
|
||||
</div>
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_DATA}/${name}`, bindKeyData)}>
|
||||
{getIcon(modeData)}
|
||||
</button>
|
||||
<span class="name">DATA</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeData == MODE_SELECTIVE || modeData == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeData == MODE_SHINY} list={filterList(listX, ["PLUGIN_DATA"])} hidden={false} />
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[modeData]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="noterow">
|
||||
<div class="statusnote">{TITLES[modeAll]}</div>
|
||||
</div>
|
||||
{#if useSyncPluginEtc}
|
||||
<div class="filerow {hideEven ? 'hideeven' : ''}">
|
||||
<div class="filetitle">
|
||||
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ETC}/${name}`, bindKeyETC)}>
|
||||
{getIcon(modeEtc)}
|
||||
</button>
|
||||
<span class="name">Other files</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeEtc == MODE_SELECTIVE || modeEtc == MODE_SHINY}
|
||||
<PluginCombo {...options} isFlagged={modeEtc == MODE_SHINY} list={filterList(listX, ["PLUGIN_ETC"])} hidden={false} />
|
||||
{:else}
|
||||
<div class="statusnote">{TITLES[modeEtc]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isMaintenanceMode}
|
||||
<div class="list">
|
||||
<div>
|
||||
<h3>Maintenance Commands</h3>
|
||||
<div class="maintenancerow">
|
||||
<label for="">Delete All of </label>
|
||||
<select bind:value={deleteTerm}>
|
||||
{#each allTerms as term}
|
||||
<option value={term}>{term}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="status"
|
||||
on:click={(evt) => {
|
||||
deleteAllItems(deleteTerm);
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="noterow">
|
||||
<div class="statusnote">{TITLES[modeAll]}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isMaintenanceMode}
|
||||
<div class="buttons">
|
||||
<label><span>Hide not applicable items</span><input type="checkbox" bind:checked={hideEven} /></label>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<label><span>Maintenance mode</span><input type="checkbox" bind:checked={isMaintenanceMode} /></label>
|
||||
<div>
|
||||
<h3>Maintenance Commands</h3>
|
||||
<div class="maintenancerow">
|
||||
<label for="">Delete All of </label>
|
||||
<select bind:value={deleteTerm}>
|
||||
{#each allTerms as term}
|
||||
<option value={term}>{term}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="status"
|
||||
on:click={(evt) => {
|
||||
deleteAllItems(deleteTerm);
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="buttons">
|
||||
<label><span>Hide not applicable items</span><input type="checkbox" bind:checked={hideEven} /></label>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<label><span>Maintenance mode</span><input type="checkbox" bind:checked={isMaintenanceMode} /></label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
span.spacer {
|
||||
min-width: 1px;
|
||||
flex-grow: 1;
|
||||
.buttonsWrap {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
h3 {
|
||||
position: sticky;
|
||||
@@ -414,13 +492,23 @@
|
||||
min-width: 10em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.title {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
line-height: var(--line-height-tight);
|
||||
margin-right: auto;
|
||||
}
|
||||
.body {
|
||||
/* margin-left: 0.4em; */
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
/* flex-wrap: wrap; */
|
||||
}
|
||||
.filetitle {
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-medium);
|
||||
@@ -467,4 +555,24 @@
|
||||
margin-right: 0.5em;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.loading {
|
||||
transition: height 0.25s ease-in-out;
|
||||
transition-delay: 4ms;
|
||||
overflow-y: hidden;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
.loading:empty {
|
||||
height: 0px;
|
||||
transition: height 0.25s ease-in-out;
|
||||
transition-delay: 1s;
|
||||
}
|
||||
.loading:not(:empty) {
|
||||
height: 2em;
|
||||
transition: height 0.25s ease-in-out;
|
||||
transition-delay: 0;
|
||||
}
|
||||
</style>
|
||||
79
src/ui/components/MultipleRegExpControl.svelte
Normal file
79
src/ui/components/MultipleRegExpControl.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
export let patterns = [] as string[];
|
||||
export let originals = [] as string[];
|
||||
|
||||
export let apply: (args: string[]) => Promise<void> = (_: string[]) => Promise.resolve();
|
||||
function revert() {
|
||||
patterns = [...originals];
|
||||
}
|
||||
const CHECK_OK = "✔";
|
||||
const CHECK_NG = "⚠";
|
||||
const MARK_MODIFIED = "✏ ";
|
||||
function checkRegExp(pattern: string) {
|
||||
if (pattern.trim() == "") return "";
|
||||
try {
|
||||
const _ = new RegExp(pattern);
|
||||
return CHECK_OK;
|
||||
} catch (ex) {
|
||||
return CHECK_NG;
|
||||
}
|
||||
}
|
||||
$: status = patterns.map((e) => checkRegExp(e));
|
||||
$: modified = patterns.map((e, i) => (e != originals?.[i] ?? "" ? MARK_MODIFIED : ""));
|
||||
|
||||
function remove(idx: number) {
|
||||
patterns[idx] = "";
|
||||
}
|
||||
function add() {
|
||||
patterns = [...patterns, ""];
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each patterns as pattern, idx}
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<li><label>{modified[idx]}{status[idx]}</label><input type="text" bind:value={pattern} class={modified[idx]} /><button class="iconbutton" on:click={() => remove(idx)}>🗑</button></li>
|
||||
{/each}
|
||||
<li>
|
||||
<label><button on:click={() => add()}>Add</button></label>
|
||||
</li>
|
||||
<li class="buttons">
|
||||
<button on:click={() => apply(patterns)} disabled={status.some((e) => e == CHECK_NG) || modified.every((e) => e == "")}>Apply</button>
|
||||
<button on:click={() => revert()} disabled={status.some((e) => e == CHECK_NG) || modified.every((e) => e == "")}>Revert</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
label {
|
||||
min-width: 4em;
|
||||
width: 4em;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
ul {
|
||||
flex-grow: 1;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
list-style-type: none;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
li {
|
||||
padding: var(--size-2-1) var(--size-4-1);
|
||||
display: inline-flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
li input {
|
||||
min-width: 10em;
|
||||
}
|
||||
button.iconbutton {
|
||||
max-width: 4em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,39 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { PluginDataExDisplay } from "./CmdConfigSync";
|
||||
import { Logger } from "./lib/src/logger";
|
||||
import { versionNumberString2Number } from "./lib/src/strbin";
|
||||
import { type FilePath, LOG_LEVEL_NOTICE } from "./lib/src/types";
|
||||
import { getDocData } from "./lib/src/utils";
|
||||
import type ObsidianLiveSyncPlugin from "./main";
|
||||
import { askString, scheduleTask } from "./utils";
|
||||
import { PluginDataExDisplayV2, type IPluginDataExDisplay } from "../../features/CmdConfigSync";
|
||||
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";
|
||||
import type ObsidianLiveSyncPlugin from "../../main";
|
||||
import { askString } from "../../common/utils";
|
||||
import { Menu } from "obsidian";
|
||||
|
||||
export let list: PluginDataExDisplay[] = [];
|
||||
export let list: IPluginDataExDisplay[] = [];
|
||||
export let thisTerm = "";
|
||||
export let hideNotApplicable = false;
|
||||
export let selectNewest = 0;
|
||||
export let selectNewestStyle = 0;
|
||||
export let applyAllPluse = 0;
|
||||
|
||||
export let applyData: (data: PluginDataExDisplay) => Promise<boolean>;
|
||||
export let compareData: (dataA: PluginDataExDisplay, dataB: PluginDataExDisplay) => Promise<boolean>;
|
||||
export let deleteData: (data: PluginDataExDisplay) => Promise<boolean>;
|
||||
export let applyData: (data: IPluginDataExDisplay) => 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;
|
||||
export let isMaintenanceMode: boolean = false;
|
||||
export let isFlagged: boolean = false;
|
||||
const addOn = plugin.addOnConfigSync;
|
||||
|
||||
let selected = "";
|
||||
export let selected = "";
|
||||
let freshness = "";
|
||||
let equivalency = "";
|
||||
let version = "";
|
||||
let canApply: boolean = false;
|
||||
let canCompare: boolean = false;
|
||||
let pickToCompare: boolean = false;
|
||||
let currentSelectNewest = 0;
|
||||
let currentApplyAll = 0;
|
||||
|
||||
// Selectable terminals
|
||||
let terms = [] as string[];
|
||||
|
||||
async function comparePlugin(local: PluginDataExDisplay, remote: PluginDataExDisplay) {
|
||||
async function comparePlugin(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined) {
|
||||
let freshness = "";
|
||||
let equivalency = "";
|
||||
let version = "";
|
||||
@@ -41,25 +44,28 @@
|
||||
let canApply: boolean = false;
|
||||
let canCompare = false;
|
||||
if (!local && !remote) {
|
||||
// NO OP. whats happened?
|
||||
// NO OP. what's happened?
|
||||
freshness = "";
|
||||
} else if (local && !remote) {
|
||||
freshness = "⚠ Local only";
|
||||
freshness = "Local only";
|
||||
} else if (remote && !local) {
|
||||
freshness = "✓ Remote only";
|
||||
freshness = "Remote only";
|
||||
canApply = true;
|
||||
} else {
|
||||
const dtDiff = (local?.mtime ?? 0) - (remote?.mtime ?? 0);
|
||||
const diff = timeDeltaToHumanReadable(Math.abs(dtDiff));
|
||||
if (dtDiff / 1000 < -10) {
|
||||
freshness = "✓ Newer";
|
||||
// freshness = "✓ Newer";
|
||||
freshness = `Newer (${diff})`;
|
||||
canApply = true;
|
||||
contentCheck = true;
|
||||
} else if (dtDiff / 1000 > 10) {
|
||||
freshness = "⚠ Older";
|
||||
// freshness = "⚠ Older";
|
||||
freshness = `Older (${diff})`;
|
||||
canApply = true;
|
||||
contentCheck = true;
|
||||
} else {
|
||||
freshness = "⚖️ Same old";
|
||||
freshness = "Same";
|
||||
canApply = false;
|
||||
contentCheck = true;
|
||||
}
|
||||
@@ -67,25 +73,26 @@
|
||||
const localVersionStr = local?.version || "0.0.0";
|
||||
const remoteVersionStr = remote?.version || "0.0.0";
|
||||
if (local?.version || remote?.version) {
|
||||
const localVersion = versionNumberString2Number(localVersionStr);
|
||||
const remoteVersion = versionNumberString2Number(remoteVersionStr);
|
||||
if (localVersion == remoteVersion) {
|
||||
version = "⚖️ Same ver.";
|
||||
} else if (localVersion > remoteVersion) {
|
||||
version = `⚠ Lower ${localVersionStr} > ${remoteVersionStr}`;
|
||||
} else if (localVersion < remoteVersion) {
|
||||
version = `✓ Higher ${localVersionStr} < ${remoteVersionStr}`;
|
||||
const compare = `${localVersionStr}`.localeCompare(remoteVersionStr, undefined, { numeric: true });
|
||||
if (compare == 0) {
|
||||
version = "Same";
|
||||
} else if (compare < 0) {
|
||||
version = `Lower (${localVersionStr} < ${remoteVersionStr})`;
|
||||
} else if (compare > 0) {
|
||||
version = `Higher (${localVersionStr} > ${remoteVersionStr})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentCheck) {
|
||||
const { canApply, equivalency, canCompare } = await checkEquivalency(local, remote);
|
||||
return { canApply, freshness, equivalency, version, canCompare };
|
||||
if (local && remote) {
|
||||
const { canApply, equivalency, canCompare } = await checkEquivalency(local, remote);
|
||||
return { canApply, freshness, equivalency, version, canCompare };
|
||||
}
|
||||
}
|
||||
return { canApply, freshness, equivalency, version, canCompare };
|
||||
}
|
||||
|
||||
async function checkEquivalency(local: PluginDataExDisplay, remote: PluginDataExDisplay) {
|
||||
async function checkEquivalency(local: IPluginDataExDisplay, remote: IPluginDataExDisplay) {
|
||||
let equivalency = "";
|
||||
let canApply = false;
|
||||
let canCompare = false;
|
||||
@@ -100,17 +107,21 @@
|
||||
return 0b0000010; //"LOCAL_ONLY";
|
||||
} else if (!localFile && remoteFile) {
|
||||
return 0b0001000; //"REMOTE ONLY"
|
||||
} else {
|
||||
if (getDocData(localFile.data) == getDocData(remoteFile.data)) {
|
||||
} else if (localFile && remoteFile) {
|
||||
const localDoc = getDocData(localFile.data);
|
||||
const remoteDoc = getDocData(remoteFile.data);
|
||||
if (localDoc == remoteDoc) {
|
||||
return 0b0000100; //"EVEN"
|
||||
} else {
|
||||
return 0b0010000; //"DIFFERENT";
|
||||
}
|
||||
} else {
|
||||
return 0b0010000; //"DIFFERENT";
|
||||
}
|
||||
})
|
||||
.reduce((p, c) => p | (c as number), 0 as number);
|
||||
if (matchingStatus == 0b0000100) {
|
||||
equivalency = "⚖️ Same";
|
||||
equivalency = "Same";
|
||||
canApply = false;
|
||||
} else if (matchingStatus <= 0b0000100) {
|
||||
equivalency = "Same or local only";
|
||||
@@ -118,30 +129,37 @@
|
||||
} else if (matchingStatus == 0b0010000) {
|
||||
canApply = true;
|
||||
canCompare = true;
|
||||
equivalency = "≠ Different";
|
||||
equivalency = "Different";
|
||||
} else {
|
||||
canApply = true;
|
||||
canCompare = true;
|
||||
equivalency = "≠ Different";
|
||||
equivalency = "Mixed";
|
||||
}
|
||||
return { equivalency, canApply, canCompare };
|
||||
}
|
||||
|
||||
async function performCompare(local: PluginDataExDisplay, remote: PluginDataExDisplay) {
|
||||
async function performCompare(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined) {
|
||||
const result = await comparePlugin(local, remote);
|
||||
canApply = result.canApply;
|
||||
freshness = result.freshness;
|
||||
equivalency = result.equivalency;
|
||||
version = result.version;
|
||||
canCompare = result.canCompare;
|
||||
if (local?.files.length != 1 || !local?.files?.first()?.filename?.endsWith(".json")) {
|
||||
canCompare = false;
|
||||
pickToCompare = false;
|
||||
if (canCompare) {
|
||||
if (local?.files.length == remote?.files.length && local?.files.length == 1 && local?.files[0].filename == remote?.files[0].filename) {
|
||||
pickToCompare = false;
|
||||
} else {
|
||||
pickToCompare = true;
|
||||
// pickToCompare = false;
|
||||
// canCompare = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTerms(list: PluginDataExDisplay[], selectNewest: boolean, isMaintenanceMode: boolean) {
|
||||
async function updateTerms(list: IPluginDataExDisplay[], selectNewest: boolean, isMaintenanceMode: boolean) {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
selected = "";
|
||||
// selected = "";
|
||||
if (isMaintenanceMode) {
|
||||
terms = [...new Set(list.map((e) => e.term))];
|
||||
} else if (hideNotApplicable) {
|
||||
@@ -157,7 +175,7 @@
|
||||
} else {
|
||||
terms = [...new Set(list.map((e) => e.term))].filter((e) => e != thisTerm);
|
||||
}
|
||||
let newest: PluginDataExDisplay = local;
|
||||
let newest: IPluginDataExDisplay | undefined = local;
|
||||
if (selectNewest) {
|
||||
for (const term of terms) {
|
||||
const remote = list.find((e) => e.term == term);
|
||||
@@ -170,12 +188,25 @@
|
||||
}
|
||||
// selectNewest = false;
|
||||
}
|
||||
if (terms.indexOf(selected) < 0) {
|
||||
selected = "";
|
||||
}
|
||||
}
|
||||
$: {
|
||||
// React pulse and select
|
||||
const doSelectNewest = selectNewest != currentSelectNewest;
|
||||
currentSelectNewest = selectNewest;
|
||||
let doSelectNewest = false;
|
||||
if (selectNewest != currentSelectNewest) {
|
||||
if (selectNewestStyle == 1) {
|
||||
doSelectNewest = true;
|
||||
} else if (selectNewestStyle == 2) {
|
||||
doSelectNewest = isFlagged;
|
||||
} else if (selectNewestStyle == 3) {
|
||||
selected = "";
|
||||
}
|
||||
// currentSelectNewest = selectNewest;
|
||||
}
|
||||
updateTerms(list, doSelectNewest, isMaintenanceMode);
|
||||
currentSelectNewest = selectNewest;
|
||||
}
|
||||
$: {
|
||||
// React pulse and apply
|
||||
@@ -213,10 +244,52 @@
|
||||
async function compareSelected() {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
if (local && selectedItem && (await compareData(local, selectedItem))) {
|
||||
addOn.updatePluginList(true, local.documentPath);
|
||||
await compareItems(local, selectedItem);
|
||||
}
|
||||
async function compareItems(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined, filename?: string) {
|
||||
if (local && remote) {
|
||||
if (!filename) {
|
||||
if (await compareData(local, remote)) {
|
||||
addOn.updatePluginList(true, local.documentPath);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
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)) {
|
||||
addOn.updatePluginList(true, local.documentPath);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
if (!remote && !local) {
|
||||
Logger(`Could not find both remote and local item`, LOG_LEVEL_INFO);
|
||||
} else if (!remote) {
|
||||
Logger(`Could not find remote item`, LOG_LEVEL_INFO);
|
||||
} else if (!local) {
|
||||
Logger(`Could not locally item`, LOG_LEVEL_INFO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pickCompareItem(evt: MouseEvent) {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
if (!local) return;
|
||||
if (!selectedItem) return;
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle("Compare file").setIsLabel(true));
|
||||
menu.addSeparator();
|
||||
const files = unique(local.files.map((e) => e.filename).concat(selectedItem.files.map((e) => e.filename)));
|
||||
for (const filename of files) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(filename).onClick((e) => compareItems(local, selectedItem, filename));
|
||||
});
|
||||
}
|
||||
menu.showAtMouseEvent(evt);
|
||||
}
|
||||
async function deleteSelected() {
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
// const deletedPath = selectedItem.documentPath;
|
||||
@@ -226,6 +299,10 @@
|
||||
}
|
||||
async function duplicateItem() {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
if (!local) {
|
||||
Logger(`Could not find local item`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const duplicateTermName = await askString(plugin.app, "Duplicate", "device name", "");
|
||||
if (duplicateTermName) {
|
||||
if (duplicateTermName.contains("/")) {
|
||||
@@ -242,10 +319,10 @@
|
||||
{#if terms.length > 0}
|
||||
<span class="spacer" />
|
||||
{#if !hidden}
|
||||
<span class="messages">
|
||||
<span class="message">{freshness}</span>
|
||||
<span class="message">{equivalency}</span>
|
||||
<span class="message">{version}</span>
|
||||
<span class="chip-wrap">
|
||||
<span class="chip modified">{freshness}</span>
|
||||
<span class="chip content">{equivalency}</span>
|
||||
<span class="chip version">{version}</span>
|
||||
</span>
|
||||
<select bind:value={selected}>
|
||||
<option value={""}>-</option>
|
||||
@@ -255,7 +332,12 @@
|
||||
</select>
|
||||
{#if canApply || (isMaintenanceMode && selected != "")}
|
||||
{#if canCompare}
|
||||
<button on:click={compareSelected}>🔍</button>
|
||||
{#if pickToCompare}
|
||||
<button on:click={pickCompareItem}>🗃️</button>
|
||||
{:else}
|
||||
<!--🔍 -->
|
||||
<button on:click={compareSelected}>⮂</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button disabled />
|
||||
{/if}
|
||||
@@ -307,12 +389,46 @@
|
||||
padding: 0 1em;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
span.messages {
|
||||
/* span.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
} */
|
||||
:global(.is-mobile) .spacer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chip-wrap {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.chip {
|
||||
display: inline-block;
|
||||
border-radius: 2px;
|
||||
font-size: 0.8em;
|
||||
padding: 0 4px;
|
||||
margin: 0 2px;
|
||||
border-color: var(--tag-border-color);
|
||||
background-color: var(--tag-background);
|
||||
color: var(--tag-color);
|
||||
}
|
||||
.chip:empty {
|
||||
display: none;
|
||||
}
|
||||
.chip:not(:empty)::before {
|
||||
min-width: 1.8em;
|
||||
display: inline-block;
|
||||
}
|
||||
.chip.content:not(:empty)::before {
|
||||
content: "📄: ";
|
||||
}
|
||||
.chip.version:not(:empty)::before {
|
||||
content: "🏷️: ";
|
||||
}
|
||||
.chip.modified:not(:empty)::before {
|
||||
content: "📅: ";
|
||||
}
|
||||
</style>
|
||||
359
src/ui/settingConstants.ts
Normal file
359
src/ui/settingConstants.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { $t } from "src/lib/src/common/i18n";
|
||||
import { DEFAULT_SETTINGS, configurationNames, type ConfigurationItem, type FilterBooleanKeys, type FilterNumberKeys, type FilterStringKeys, type ObsidianLiveSyncSettings } from "src/lib/src/common/types";
|
||||
|
||||
export type OnDialogSettings = {
|
||||
configPassphrase: string,
|
||||
preset: "" | "PERIODIC" | "LIVESYNC" | "DISABLE",
|
||||
syncMode: "ONEVENTS" | "PERIODIC" | "LIVESYNC"
|
||||
dummy: number,
|
||||
deviceAndVaultName: string,
|
||||
}
|
||||
|
||||
export const OnDialogSettingsDefault: OnDialogSettings = {
|
||||
configPassphrase: "",
|
||||
preset: "",
|
||||
syncMode: "ONEVENTS",
|
||||
dummy: 0,
|
||||
deviceAndVaultName: "",
|
||||
}
|
||||
export const AllSettingDefault =
|
||||
{ ...DEFAULT_SETTINGS, ...OnDialogSettingsDefault }
|
||||
|
||||
export type AllSettings = ObsidianLiveSyncSettings & OnDialogSettings;
|
||||
export type AllStringItemKey = FilterStringKeys<AllSettings>;
|
||||
export type AllNumericItemKey = FilterNumberKeys<AllSettings>;
|
||||
export type AllBooleanItemKey = FilterBooleanKeys<AllSettings>;
|
||||
export type AllSettingItemKey = AllStringItemKey | AllNumericItemKey | AllBooleanItemKey;
|
||||
|
||||
export type ValueOf<T extends AllSettingItemKey> =
|
||||
T extends AllStringItemKey ? string :
|
||||
T extends AllNumericItemKey ? number :
|
||||
T extends AllBooleanItemKey ? boolean :
|
||||
AllSettings[T];
|
||||
|
||||
export const SettingInformation: Partial<Record<keyof AllSettings, ConfigurationItem>> = {
|
||||
"liveSync": {
|
||||
"name": "Sync Mode"
|
||||
},
|
||||
"couchDB_URI": {
|
||||
"name": "URI",
|
||||
"placeHolder": "https://........"
|
||||
},
|
||||
"couchDB_USER": {
|
||||
"name": "Username",
|
||||
"desc": "username"
|
||||
},
|
||||
"couchDB_PASSWORD": {
|
||||
"name": "Password",
|
||||
"desc": "password"
|
||||
},
|
||||
"couchDB_DBNAME": {
|
||||
"name": "Database name"
|
||||
},
|
||||
"passphrase": {
|
||||
"name": "Passphrase",
|
||||
"desc": "Encrypting passphrase. If you change the passphrase of an existing database, overwriting the remote database is strongly recommended."
|
||||
},
|
||||
"showStatusOnEditor": {
|
||||
"name": "Show status inside the editor",
|
||||
"desc": "Reflected after reboot"
|
||||
},
|
||||
"showOnlyIconsOnEditor": {
|
||||
"name": "Show status as icons only"
|
||||
},
|
||||
"showStatusOnStatusbar": {
|
||||
"name": "Show status on the status bar",
|
||||
"desc": "Reflected after reboot."
|
||||
},
|
||||
"lessInformationInLog": {
|
||||
"name": "Show only notifications",
|
||||
"desc": "Prevent logging and show only notification. Please disable when you report the logs"
|
||||
},
|
||||
"showVerboseLog": {
|
||||
"name": "Verbose Log",
|
||||
"desc": "Show verbose log. Please enable when you report the logs"
|
||||
},
|
||||
"hashCacheMaxCount": {
|
||||
"name": "Memory cache size (by total items)"
|
||||
},
|
||||
"hashCacheMaxAmount": {
|
||||
"name": "Memory cache size (by total characters)",
|
||||
"desc": "(Mega chars)"
|
||||
},
|
||||
"writeCredentialsForSettingSync": {
|
||||
"name": "Write credentials in the file",
|
||||
"desc": "(Not recommended) If set, credentials will be stored in the file."
|
||||
},
|
||||
"notifyAllSettingSyncFile": {
|
||||
"name": "Notify all setting files"
|
||||
},
|
||||
"configPassphrase": {
|
||||
"name": "Passphrase of sensitive configuration items",
|
||||
"desc": "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again."
|
||||
},
|
||||
"configPassphraseStore": {
|
||||
"name": "Encrypting sensitive configuration items"
|
||||
},
|
||||
"syncOnSave": {
|
||||
"name": "Sync on Save",
|
||||
"desc": "When you save a file, sync automatically"
|
||||
},
|
||||
"syncOnEditorSave": {
|
||||
"name": "Sync on Editor Save",
|
||||
"desc": "When you save a file in the editor, sync automatically"
|
||||
},
|
||||
"syncOnFileOpen": {
|
||||
"name": "Sync on File Open",
|
||||
"desc": "When you open a file, sync automatically"
|
||||
},
|
||||
"syncOnStart": {
|
||||
"name": "Sync on Start",
|
||||
"desc": "Start synchronization after launching Obsidian."
|
||||
},
|
||||
"syncAfterMerge": {
|
||||
"name": "Sync after merging file",
|
||||
"desc": "Sync automatically after merging files"
|
||||
},
|
||||
"trashInsteadDelete": {
|
||||
"name": "Use the trash bin",
|
||||
"desc": "Do not delete files that are deleted in remote, just move to trash."
|
||||
},
|
||||
"doNotDeleteFolder": {
|
||||
"name": "Keep empty folder",
|
||||
"desc": "Normally, a folder is deleted when it becomes empty after a synchronization. Enabling this will prevent it from getting deleted"
|
||||
},
|
||||
"resolveConflictsByNewerFile": {
|
||||
"name": "Always overwrite with a newer file (beta)",
|
||||
"desc": "(Def off) Resolve conflicts by newer files automatically."
|
||||
},
|
||||
"checkConflictOnlyOnOpen": {
|
||||
"name": "Postpone resolution of inactive files"
|
||||
},
|
||||
"showMergeDialogOnlyOnActive": {
|
||||
"name": "Postpone manual resolution of inactive files"
|
||||
},
|
||||
"disableMarkdownAutoMerge": {
|
||||
"name": "Always resolve conflicts manually",
|
||||
"desc": "If this switch is turned on, a merge dialog will be displayed, even if the sensible-merge is possible automatically. (Turn on to previous behavior)"
|
||||
},
|
||||
"writeDocumentsIfConflicted": {
|
||||
"name": "Always reflect synchronized changes even if the note has a conflict",
|
||||
"desc": "Turn on to previous behavior"
|
||||
},
|
||||
"syncInternalFilesInterval": {
|
||||
"name": "Scan hidden files periodically",
|
||||
"desc": "Seconds, 0 to disable"
|
||||
},
|
||||
"batchSave": {
|
||||
"name": "Batch database update",
|
||||
"desc": "Reducing the frequency with which on-disk changes are reflected into the DB"
|
||||
},
|
||||
"readChunksOnline": {
|
||||
"name": "Fetch chunks on demand",
|
||||
"desc": "(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended."
|
||||
},
|
||||
"syncMaxSizeInMB": {
|
||||
"name": "Maximum file size",
|
||||
"desc": "(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used."
|
||||
},
|
||||
"useIgnoreFiles": {
|
||||
"name": "(Beta) Use ignore files",
|
||||
"desc": "If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files."
|
||||
},
|
||||
"ignoreFiles": {
|
||||
"name": "Ignore files",
|
||||
"desc": "We can use multiple ignore files, e.g.) `.gitignore, .dockerignore`"
|
||||
},
|
||||
"batch_size": {
|
||||
"name": "Batch size",
|
||||
"desc": "Number of change feed items to process at a time. Defaults to 50. Minimum is 2."
|
||||
},
|
||||
"batches_limit": {
|
||||
"name": "Batch limit",
|
||||
"desc": "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time."
|
||||
},
|
||||
"useTimeouts": {
|
||||
"name": "Use timeouts instead of heartbeats",
|
||||
"desc": "If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage."
|
||||
},
|
||||
"concurrencyOfReadChunksOnline": {
|
||||
"name": "Batch size of on-demand fetching"
|
||||
},
|
||||
"minimumIntervalOfReadChunksOnline": {
|
||||
"name": "The delay for consecutive on-demand fetches"
|
||||
},
|
||||
"suspendFileWatching": {
|
||||
"name": "Suspend file watching",
|
||||
"desc": "Stop watching for file change."
|
||||
},
|
||||
"suspendParseReplicationResult": {
|
||||
"name": "Suspend database reflecting",
|
||||
"desc": "Stop reflecting database changes to storage files."
|
||||
},
|
||||
"writeLogToTheFile": {
|
||||
"name": "Write logs into the file",
|
||||
"desc": "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information."
|
||||
},
|
||||
"deleteMetadataOfDeletedFiles": {
|
||||
"name": "Do not keep metadata of deleted files."
|
||||
},
|
||||
"useIndexedDBAdapter": {
|
||||
"name": "Use an old adapter for compatibility",
|
||||
"desc": "Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this."
|
||||
},
|
||||
"watchInternalFileChanges": {
|
||||
"name": "Scan changes on customization sync",
|
||||
"desc": "Do not use internal API"
|
||||
},
|
||||
"doNotSuspendOnFetching": {
|
||||
"name": "Fetch database with previous behaviour"
|
||||
},
|
||||
"disableCheckingConfigMismatch": {
|
||||
"name": "Do not check configuration mismatch before replication"
|
||||
},
|
||||
"usePluginSync": {
|
||||
"name": "Enable customization sync"
|
||||
},
|
||||
"autoSweepPlugins": {
|
||||
"name": "Scan customization automatically",
|
||||
"desc": "Scan customization before replicating."
|
||||
},
|
||||
"autoSweepPluginsPeriodic": {
|
||||
"name": "Scan customization periodically",
|
||||
"desc": "Scan customization every 1 minute."
|
||||
},
|
||||
"notifyPluginOrSettingUpdated": {
|
||||
"name": "Notify customized",
|
||||
"desc": "Notify when other device has newly customized."
|
||||
},
|
||||
"remoteType": {
|
||||
"name": "Remote Type",
|
||||
"desc": "Remote server type"
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "Endpoint URL",
|
||||
"placeHolder": "https://........"
|
||||
},
|
||||
"accessKey": {
|
||||
"name": "Access Key"
|
||||
},
|
||||
"secretKey": {
|
||||
"name": "Secret Key"
|
||||
},
|
||||
"region": {
|
||||
"name": "Region",
|
||||
"placeHolder": "auto"
|
||||
},
|
||||
"bucket": {
|
||||
"name": "Bucket Name"
|
||||
},
|
||||
"useCustomRequestHandler": {
|
||||
"name": "Use Custom HTTP Handler",
|
||||
"desc": "If your Object Storage could not configured accepting CORS, enable this."
|
||||
},
|
||||
"maxChunksInEden": {
|
||||
"name": "Maximum Incubating Chunks",
|
||||
"desc": "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks."
|
||||
},
|
||||
"maxTotalLengthInEden": {
|
||||
"name": "Maximum Incubating Chunk Size",
|
||||
"desc": "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks."
|
||||
},
|
||||
"maxAgeInEden": {
|
||||
"name": "Maximum Incubation Period",
|
||||
"desc": "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks."
|
||||
},
|
||||
"settingSyncFile": {
|
||||
"name": "Filename",
|
||||
"desc": "If you set this, all settings are saved in a markdown file. You will be notified when new settings arrive. You can set different files by the platform."
|
||||
},
|
||||
"preset": {
|
||||
"name": "Presets",
|
||||
"desc": "Apply preset configuration"
|
||||
},
|
||||
"syncMode": {
|
||||
name: "Sync Mode",
|
||||
},
|
||||
"periodicReplicationInterval": {
|
||||
"name": "Periodic Sync interval",
|
||||
"desc": "Interval (sec)"
|
||||
},
|
||||
"syncInternalFilesBeforeReplication": {
|
||||
"name": "Scan for hidden files before replication"
|
||||
},
|
||||
"automaticallyDeleteMetadataOfDeletedFiles": {
|
||||
"name": "Delete old metadata of deleted files on start-up",
|
||||
"desc": "(Days passed, 0 to disable automatic-deletion)"
|
||||
},
|
||||
"additionalSuffixOfDatabaseName": {
|
||||
"name": "Database suffix",
|
||||
"desc": "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured."
|
||||
},
|
||||
"hashAlg": {
|
||||
"name": configurationNames["hashAlg"]?.name || "",
|
||||
"desc": "xxhash64 is the current default."
|
||||
},
|
||||
"deviceAndVaultName": {
|
||||
"name": "Device name",
|
||||
"desc": "Unique name between all synchronized devices. To edit this setting, please disable customization sync once."
|
||||
},
|
||||
"displayLanguage": {
|
||||
"name": "Display Language",
|
||||
"desc": "Not all messages have been translated. And, please revert to \"Default\" when reporting errors."
|
||||
},
|
||||
enableChunkSplitterV2: {
|
||||
name: "Use splitting-limit-capped chunk splitter",
|
||||
desc: "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker."
|
||||
},
|
||||
disableWorkerForGeneratingChunks: {
|
||||
name: "Do not split chunks in the background",
|
||||
desc: "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour)."
|
||||
},
|
||||
processSmallFilesInUIThread: {
|
||||
name: "Process small files in the foreground",
|
||||
desc: "If enabled, the file under 1kb will be processed in the UI thread."
|
||||
},
|
||||
batchSaveMinimumDelay: {
|
||||
name: "Minimum delay for batch database updating",
|
||||
desc: "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving."
|
||||
},
|
||||
batchSaveMaximumDelay: {
|
||||
name: "Maximum delay for batch database updating",
|
||||
desc: "Saving will be performed forcefully after this number of seconds."
|
||||
},
|
||||
"notifyThresholdOfRemoteStorageSize": {
|
||||
name: "Notify when the estimated remote storage size exceeds on start up",
|
||||
desc: "MB (0 to disable)."
|
||||
},
|
||||
"usePluginSyncV2": {
|
||||
name: "Enable per-file-saved customization sync",
|
||||
desc: "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions."
|
||||
}
|
||||
}
|
||||
function translateInfo(infoSrc: ConfigurationItem | undefined | false) {
|
||||
if (!infoSrc) return false;
|
||||
const info = { ...infoSrc };
|
||||
info.name = $t(info.name);
|
||||
if (info.desc) {
|
||||
info.desc = $t(info.desc);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
function _getConfig(key: AllSettingItemKey) {
|
||||
|
||||
if (key in configurationNames) {
|
||||
return configurationNames[key as keyof ObsidianLiveSyncSettings];
|
||||
}
|
||||
if (key in SettingInformation) {
|
||||
return SettingInformation[key as keyof ObsidianLiveSyncSettings];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export function getConfig(key: AllSettingItemKey) {
|
||||
return translateInfo(_getConfig(key));
|
||||
}
|
||||
export function getConfName(key: AllSettingItemKey) {
|
||||
const conf = getConfig(key);
|
||||
if (!conf) return `${key} (No info)`;
|
||||
return conf.name;
|
||||
}
|
||||
32
styles.css
32
styles.css
@@ -77,14 +77,6 @@
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
/* .sls-table-head{
|
||||
width:50%;
|
||||
}
|
||||
.sls-table-tail{
|
||||
width:50%;
|
||||
|
||||
} */
|
||||
|
||||
.sls-header-button {
|
||||
margin-left: 2em;
|
||||
}
|
||||
@@ -94,7 +86,7 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--slsmessage: "";
|
||||
--sls-log-text: "";
|
||||
}
|
||||
|
||||
.sls-troubleshoot-preview {
|
||||
@@ -110,7 +102,10 @@
|
||||
.markdown-source-view.cm-s-obsidian::before,
|
||||
.canvas-wrapper::before,
|
||||
.empty-state::before {
|
||||
content: attr(data-log);
|
||||
content: var(--sls-log-text, "");
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-variant-emoji: emoji;
|
||||
tab-size: 4;
|
||||
text-align: right;
|
||||
white-space: pre-wrap;
|
||||
position: absolute;
|
||||
@@ -138,6 +133,7 @@
|
||||
top: var(--view-header-height);
|
||||
right: 1em;
|
||||
}
|
||||
|
||||
.canvas-wrapper::before {
|
||||
right: 48px;
|
||||
}
|
||||
@@ -275,6 +271,22 @@ div.sls-setting-menu-btn {
|
||||
content: "✏";
|
||||
}
|
||||
|
||||
.sls-item-dirty-help::after {
|
||||
content: " ❓";
|
||||
}
|
||||
|
||||
.sls-item-invalid-value {
|
||||
background-color: rgba(var(--background-modifier-error-rgb), 0.3) !important;
|
||||
}
|
||||
|
||||
.sls-setting-disabled input[type=text],
|
||||
.sls-setting-disabled input[type=number],
|
||||
.sls-setting-disabled input[type=password] {
|
||||
filter: brightness(80%);
|
||||
color: var(--text-muted);
|
||||
|
||||
}
|
||||
|
||||
.sls-setting-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
107
updates.md
107
updates.md
@@ -1,77 +1,56 @@
|
||||
### 0.22.0
|
||||
A few years passed since Self-hosted LiveSync was born, and our codebase had been very complicated. This could be patient now, but it should be a tremendous hurt.
|
||||
Therefore at v0.22.0, for future maintainability, I refined task scheduling logic totally.
|
||||
### 0.23.0
|
||||
Incredibly new features!
|
||||
|
||||
Of course, I think this would be our suffering in some cases. However, I would love to ask you for your cooperation and contribution.
|
||||
Now, we can use object storage (MinIO, S3, R2 or anything you like) for synchronising! Moreover, despite that, we can use all the features as if we were using CouchDB.
|
||||
Note: As this is a pretty experimental feature, hence we have some limitations.
|
||||
- This is built on the append-only architecture. It will not shrink used storage if we do not perform a rebuild.
|
||||
- A bit fragile. However, our version x.yy.0 is always so.
|
||||
- When the first synchronisation, the entire history to date is transferred. For this reason, it is preferable to do this under the WiFi network.
|
||||
- Do not worry, from the second synchronisation, we always transfer only differences.
|
||||
|
||||
Sorry for being absent so much long. And thank you for your patience!
|
||||
I hope this feature empowers users to maintain independence and self-host their data, offering an alternative for those who prefer to manage their own storage solutions and avoid being stuck on the right side of a sudden change in business model.
|
||||
|
||||
Note: we got a very performance improvement.
|
||||
Note at 0.22.2: **Now, to rescue mobile devices, Maximum file size is set to 50 by default**. Please configure the limit as you need. If you do not want to limit the sizes, set zero manually, please.
|
||||
Of course, I use Self-hosted MinIO for testing and recommend this. It is for the same reason as using CouchDB. -- open, controllable, auditable and indeed already audited by numerous eyes.
|
||||
|
||||
Let me write one more acknowledgement.
|
||||
|
||||
I have a lot of respect for that plugin, even though it is sometimes treated as if it is a competitor, remotely-save. I think it is a great architecture that embodies a different approach to my approach of recreating history. This time, with all due respect, I have used some of its code as a reference.
|
||||
Hooray for open source, and generous licences, and the sharing of knowledge by experts.
|
||||
|
||||
#### Version history
|
||||
- 0.22.11:
|
||||
- 0.23.19:
|
||||
- Fixed:
|
||||
- `Verify and repair all files` is no longer broken.
|
||||
- Customisation Sync now checks the difference while storing or applying the configuration.
|
||||
- No longer storing the same configuration multiple times.
|
||||
- Time difference in the dialogue has been fixed.
|
||||
- 0.23.18:
|
||||
- New feature:
|
||||
- Now `Verify and repair all files` is able to...
|
||||
- Restore if the file only in the local database.
|
||||
- Show the history.
|
||||
- Per-file-saved customization sync has been shipped.
|
||||
- We can synchronise plug-igs etc., more smoothly.
|
||||
- Default: disabled. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost compatibility with old versions.
|
||||
- Customisation sync has got beta3.
|
||||
- We can set `Flag` to each item to select the newest, automatically.
|
||||
- This configuration is per device.
|
||||
- Improved:
|
||||
- Performance improved.
|
||||
- 0.22.10
|
||||
- Start-up speed has been improved.
|
||||
- Fixed:
|
||||
- No longer unchanged hidden files and customisations are saved and transferred now.
|
||||
- File integrity of vault history indicates the integrity correctly.
|
||||
- On the customisation sync dialogue, buttons are kept within the screen.
|
||||
- No more unnecessary entries on `data.json` for customisation sync.
|
||||
- Selections are no longer lost while updating customisation items.
|
||||
- Tidied on source codes:
|
||||
- Many typos have been fixed.
|
||||
- Some unnecessary type casting removed.
|
||||
- 0.23.17:
|
||||
- Improved:
|
||||
- In the report, the schema of the remote database URI is now printed.
|
||||
- 0.22.9
|
||||
- Overall performance has been improved by using PouchDB 9.0.0.
|
||||
- Configuration mismatch detection is refined. We can resolve mismatches more smoothly and naturally.
|
||||
More detail is on `troubleshooting.md` on the repository.
|
||||
- Fixed:
|
||||
- Fixed a bug on `fetch chunks on demand` that could not fetch the chunks on demand.
|
||||
- Improved:
|
||||
- `fetch chunks on demand` works more smoothly.
|
||||
- Initialisation `Fetch` is now more efficient.
|
||||
- Tidied:
|
||||
- Removed some meaningless codes.
|
||||
- 0.22.8
|
||||
- Fixed:
|
||||
- Now fetch and unlock the locked remote database works well again.
|
||||
- No longer crash on symbolic links inside hidden folders.
|
||||
- Improved:
|
||||
- Chunks are now created more efficiently.
|
||||
- Splitting old notes into a larger chunk.
|
||||
- Better performance in saving notes.
|
||||
- Network activities are indicated as an icon.
|
||||
- Less memory used for binary processing.
|
||||
- Tidied:
|
||||
- Cleaned unused functions up.
|
||||
- Sorting out the codes that have become nonsense.
|
||||
- Changed:
|
||||
- Now no longer `fetch chunks on demand` needs `Pacing replication`
|
||||
- The setting `Do not pace synchronization` has been deleted.
|
||||
- 0.22.7
|
||||
- Fixed:
|
||||
- No longer deleted hidden files were ignored.
|
||||
- The document history dialogue is now able to process the deleted revisions.
|
||||
- Deletion of a hidden file is now surely performed even if the file is already conflicted.
|
||||
- 0.22.6
|
||||
- Fixed:
|
||||
- Fixed a problem with synchronisation taking a long time to start in some cases.
|
||||
- The first synchronisation after update might take a bit longer.
|
||||
- Now we can disable E2EE encryption.
|
||||
- Improved:
|
||||
- `Setup Wizard` is now more clear.
|
||||
- `Minimal Setup` is now more simple.
|
||||
- Self-hosted LiveSync now be able to use even if there are vaults with the same name.
|
||||
- Database suffix will automatically added.
|
||||
- Now Self-hosted LiveSync waits until set-up is complete.
|
||||
- Show reload prompts when possibly recommended while settings.
|
||||
- Customisation Sync will be disabled when a corrupted configuration is detected.
|
||||
Therefore, the Device Name can be changed even in the event of a configuration mismatch.
|
||||
- New feature:
|
||||
- A guidance dialogue prompting for settings will be shown after the installation.
|
||||
- Changed
|
||||
- `Open setup URI` is now `Use the copied setup URI`
|
||||
- `Copy setup URI` is now `Copy current settings as a new setup URI`
|
||||
- `Setup Wizard` is now `Minimal Setup`
|
||||
- `Check database configuration` is now `Check and Fix database configuration`
|
||||
- We can get a notification about the storage usage of the remote database.
|
||||
- Default: We will be asked.
|
||||
- If the remote storage usage approaches the configured value, we will be asked whether we want to Rebuild or increase the limit.
|
||||
|
||||
... To continue on to `updates_old.md`.
|
||||
Older notes is in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
|
||||
277
updates_old.md
277
updates_old.md
@@ -1,3 +1,148 @@
|
||||
### 0.23.0
|
||||
Incredibly new features!
|
||||
|
||||
Now, we can use object storage (MinIO, S3, R2 or anything you like) for synchronising! Moreover, despite that, we can use all the features as if we were using CouchDB.
|
||||
Note: As this is a pretty experimental feature, hence we have some limitations.
|
||||
- This is built on the append-only architecture. It will not shrink used storage if we do not perform a rebuild.
|
||||
- A bit fragile. However, our version x.yy.0 is always so.
|
||||
- When the first synchronisation, the entire history to date is transferred. For this reason, it is preferable to do this under the WiFi network.
|
||||
- Do not worry, from the second synchronisation, we always transfer only differences.
|
||||
|
||||
I hope this feature empowers users to maintain independence and self-host their data, offering an alternative for those who prefer to manage their own storage solutions and avoid being stuck on the right side of a sudden change in business model.
|
||||
|
||||
Of course, I use Self-hosted MinIO for testing and recommend this. It is for the same reason as using CouchDB. -- open, controllable, auditable and indeed already audited by numerous eyes.
|
||||
|
||||
Let me write one more acknowledgement.
|
||||
|
||||
I have a lot of respect for that plugin, even though it is sometimes treated as if it is a competitor, remotely-save. I think it is a great architecture that embodies a different approach to my approach of recreating history. This time, with all due respect, I have used some of its code as a reference.
|
||||
Hooray for open source, and generous licences, and the sharing of knowledge by experts.
|
||||
|
||||
#### Version history
|
||||
- 0.23.16:
|
||||
- Maintenance Update:
|
||||
- Library refining (Phase 1 - step 2). There are no significant changes on the user side.
|
||||
- Including the following fixes of potentially problems:
|
||||
- the problem which the path had been obfuscating twice has been resolved.
|
||||
- Note: Potential problems of the library; which has not happened in Self-hosted LiveSync for some reasons.
|
||||
- 0.23.15:
|
||||
- Maintenance Update:
|
||||
- Library refining (Phase 1). There are no significant changes on the user side.
|
||||
- 0.23.14:
|
||||
- Fixed:
|
||||
- No longer batch-saving ignores editor inputs.
|
||||
- The file-watching and serialisation processes have been changed to the one which is similar to previous implementations.
|
||||
- We can configure the settings (Especially about text-boxes) even if we have configured the device name.
|
||||
- Improved:
|
||||
- We can configure the delay of batch-saving.
|
||||
- Default: 5 seconds, the same as the previous hard-coded value. (Note: also, the previous behaviour was not correct).
|
||||
- Also, we can configure the limit of delaying batch-saving.
|
||||
- The performance of showing status indicators has been improved.
|
||||
- 0.23.13:
|
||||
- Fixed:
|
||||
- No longer files have been trimmed even delimiters have been continuous.
|
||||
- Fixed the toggle title to `Do not split chunks in the background` from `Do not split chunks in the foreground`.
|
||||
- Non-configured item mismatches are no longer detected.
|
||||
- 0.23.12:
|
||||
- Improved:
|
||||
- Now notes will be split into chunks in the background thread to improve smoothness.
|
||||
- Default enabled, to disable, toggle `Do not split chunks in the foreground` on `Hatch` -> `Compatibility`.
|
||||
- If you want to process very small notes in the foreground, please enable `Process small files in the foreground` on `Hatch` -> `Compatibility`.
|
||||
- We can use a `splitting-limit-capped chunk splitter`; which performs more simple and make less amount of chunks.
|
||||
- Default disabled, to enable, toggle `Use splitting-limit-capped chunk splitter` on `Sync settings` -> `Performance tweaks`
|
||||
- Tidied
|
||||
- Some files have been separated into multiple files to make them more explicit in what they are responsible for.
|
||||
- 0.23.11:
|
||||
- Fixed:
|
||||
- Now we *surely* can set the device name and enable customised synchronisation.
|
||||
- Unnecessary dialogue update processes have been eliminated.
|
||||
- Customisation sync no longer stores half-collected files.
|
||||
- No longer hangs up when removing or renaming files with the `Sync on Save` toggle enabled.
|
||||
- Improved:
|
||||
- Customisation sync now performs data deserialization more smoothly.
|
||||
- New translations have been merged.
|
||||
- 0.23.10
|
||||
- Fixed:
|
||||
- No longer configurations have been locked in the minimal setup.
|
||||
- 0.23.9
|
||||
- Fixed:
|
||||
- No longer unexpected parallel replication is performed.
|
||||
- Now we can set the device name and enable customised synchronisation again.
|
||||
- 0.23.8
|
||||
- New feature:
|
||||
- Now we are ready for i18n.
|
||||
- Patch or PR of `rosetta.ts` are welcome!
|
||||
- The setting dialogue has been refined. Very controllable, clearly displayed disabled items, and ready to i18n.
|
||||
- Fixed:
|
||||
- Many memory leaks have been rescued.
|
||||
- Chunk caches now work well.
|
||||
- Many trivial but potential bugs are fixed.
|
||||
- No longer error messages will be shown on retrieving checkpoint or server information.
|
||||
- Now we can check and correct tweak mismatch during the setup
|
||||
- Improved:
|
||||
- Customisation synchronisation has got more smoother.
|
||||
- Tidied
|
||||
- Practically unused functions have been removed or are being prepared for removal.
|
||||
- Many of the type-errors and lint errors have been corrected.
|
||||
- Unused files have been removed.
|
||||
- Note:
|
||||
- From this version, some test files have been included. However, they are not enabled and released in the release build.
|
||||
- To try them, please run Self-hosted LiveSync in the dev build.
|
||||
- 0.23.7
|
||||
- Fixed:
|
||||
- No longer missing tasks which have queued as the same key (e.g., for the same operation to the same file).
|
||||
- This occurs, for example, with hidden files that have been changed multiple times in a very short period of time, such as `appearance.json`. Thanks for the report!
|
||||
- Some trivial issues have been fixed.
|
||||
- New feature:
|
||||
- Reloading Obsidian can be scheduled until that file and database operations are stable.
|
||||
- 0.23.6:
|
||||
- Fixed:
|
||||
- Now the remote chunks could be decrypted even if we are using `Incubate chunks in Document`. (The note of 0.23.6 has been fixed).
|
||||
- Chunk retrieving with `Incubate chunks in document` got more efficiently.
|
||||
- No longer task processor misses the completed tasks.
|
||||
- Replication is no longer started automatically during changes in window visibility (e.g., task switching on the desktop) when off-focused.
|
||||
- 0.23.5:
|
||||
- New feature:
|
||||
- Now we can check configuration mismatching between clients before synchronisation.
|
||||
- Default: enabled / Preferred: enabled / We can disable this by the `Do not check configuration mismatch before replication` toggle in the `Hatch` pane.
|
||||
- It detects configuration mismatches and prevents synchronisation failures and wasted storage.
|
||||
- Now we can perform remote database compaction from the `Maintenance` pane.
|
||||
- Fixed:
|
||||
- We can detect the bucket could not be reachable.
|
||||
- Note:
|
||||
- Known inexplicable behaviour: Recently, (Maybe while enabling `Incubate chunks in Document` and `Fetch chunks on demand` or some more toggles), our customisation sync data is sometimes corrupted. It will be addressed by the next release.
|
||||
- 0.23.4
|
||||
- Fixed:
|
||||
- No longer experimental configuration is shown on the Minimal Setup.
|
||||
- New feature:
|
||||
- We can now use `Incubate Chunks in Document` to reduce non-well-formed chunks.
|
||||
- Default: disabled / Preferred: enabled in all devices.
|
||||
- When we enabled this toggle, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.
|
||||
- The [design document](https://github.com/vrtmrz/obsidian-livesync/blob/3925052f9290b3579e45a4b716b3679c833d8ca0/docs/design_docs_of_keep_newborn_chunks.md) has been also available..
|
||||
- 0.23.3
|
||||
- Fixed: No longer unwanted `\f` in journal sync.
|
||||
- 0.23.2
|
||||
- Sorry for all the fixes to experimental features. (These things were also critical for dogfooding). The next release would be the main fixes! Thank you for your patience and understanding!
|
||||
- Fixed:
|
||||
- Journal Sync will not hang up during big replication, especially the initial one.
|
||||
- All changes which have been replicated while rebuilding will not be postponed (Previous behaviour).
|
||||
- Improved:
|
||||
- Now Journal Sync works efficiently in download and parse, or pack and upload.
|
||||
- Less server storage and faster packing/unpacking usage by the new chunk format.
|
||||
- 0.23.1
|
||||
- Fixed:
|
||||
- Now journal synchronisation considers untransferred each from sent and received.
|
||||
- Journal sync now handles retrying.
|
||||
- Journal synchronisation no longer considers the synchronisation of chunks as revision updates (Simply ignored).
|
||||
- Journal sync now splits the journal pack to prevent mobile device rebooting.
|
||||
- Maintenance menus which had been on the command palette are now back in the maintain pane on the setting dialogue.
|
||||
- Improved:
|
||||
- Now all changes which have been replicated while rebuilding will be postponed.
|
||||
|
||||
- 0.23.0
|
||||
- New feature:
|
||||
- Now we can use Object Storage.
|
||||
|
||||
|
||||
### 0.22.0
|
||||
A few years passed since Self-hosted LiveSync was born, and our codebase had been very complicated. This could be patient now, but it should be a tremendous hurt.
|
||||
Therefore at v0.22.0, for future maintainability, I refined task scheduling logic totally.
|
||||
@@ -10,6 +155,138 @@ Note: we got a very performance improvement.
|
||||
Note at 0.22.2: **Now, to rescue mobile devices, Maximum file size is set to 50 by default**. Please configure the limit as you need. If you do not want to limit the sizes, set zero manually, please.
|
||||
|
||||
#### Version history
|
||||
- 0.22.19
|
||||
- Fixed:
|
||||
- No longer data corrupting due to false BASE64 detections.
|
||||
- Improved:
|
||||
- A bit more efficient in Automatic data compression.
|
||||
- 0.22.18
|
||||
- New feature (Very Experimental):
|
||||
- Now we can use `Automatic data compression` to reduce amount of traffic and the usage of remote database.
|
||||
- Please make sure all devices are updated to v0.22.18 before trying this feature.
|
||||
- If you are using some other utilities which connected to your vault, please make sure that they have compatibilities.
|
||||
- Note: Setting `File Compression` on the remote database works for shrink the size of remote database. Please refer the [Doc](https://docs.couchdb.org/en/stable/config/couchdb.html#couchdb/file_compression).
|
||||
- 0.22.17:
|
||||
- Fixed:
|
||||
- Error handling on booting now works fine.
|
||||
- Replication is now started automatically in LiveSync mode.
|
||||
- Batch database update is now disabled in LiveSync mode.
|
||||
- No longer automatically reconnection while off-focused.
|
||||
- Status saves are thinned out.
|
||||
- Now Self-hosted LiveSync waits for all files between the local database and storage to be surely checked.
|
||||
- Improved:
|
||||
- The job scheduler is now more robust and stable.
|
||||
- The status indicator no longer flickers and keeps zero for a while.
|
||||
- No longer meaningless frequent updates of status indicators.
|
||||
- Now we can configure regular expression filters in handy UI. Thank you so much, @eth-p!
|
||||
- `Fetch` or `Rebuild everything` is now more safely performed.
|
||||
- Minor things
|
||||
- Some utility function has been added.
|
||||
- Customisation sync now less wrong messages.
|
||||
- Digging the weeds for eradication of type errors.
|
||||
- 0.22.16:
|
||||
- Fixed:
|
||||
- Fixed the issue that binary files were sometimes corrupted.
|
||||
- Fixed customisation sync data could be corrupted.
|
||||
- Improved:
|
||||
- Now the remote database costs lower memory.
|
||||
- This release requires a brief wait on the first synchronisation, to track the latest changeset again.
|
||||
- Description added for the `Device name`.
|
||||
- Refactored:
|
||||
- Many type-errors have been resolved.
|
||||
- Obsolete file has been deleted.
|
||||
- 0.22.15:
|
||||
- Improved:
|
||||
- Faster start-up by removing too many logs which indicates normality
|
||||
- By streamlined scanning of customised synchronisation extra phases have been deleted.
|
||||
... To continue on to `updates_old.md`.
|
||||
- 0.22.14:
|
||||
- New feature:
|
||||
- We can disable the status bar in the setting dialogue.
|
||||
- Improved:
|
||||
- Now some files are handled as correct data type.
|
||||
- Customisation sync now uses the digest of each file for better performance.
|
||||
- The status in the Editor now works performant.
|
||||
- Refactored:
|
||||
- Common functions have been ready and the codebase has been organised.
|
||||
- Stricter type checking following TypeScript updates.
|
||||
- Remove old iOS workaround for simplicity and performance.
|
||||
- 0.22.13:
|
||||
- Improved:
|
||||
- Now using HTTP for the remote database URI warns of an error (on mobile) or notice (on desktop).
|
||||
- Refactored:
|
||||
- Dependencies have been polished.
|
||||
- 0.22.12:
|
||||
- Changed:
|
||||
- The default settings has been changed.
|
||||
- Improved:
|
||||
- Default and preferred settings are applied on completion of the wizard.
|
||||
- Fixed:
|
||||
- Now Initialisation `Fetch` will be performed smoothly and there will be fewer conflicts.
|
||||
- No longer stuck while Handling transferred or initialised documents.
|
||||
- 0.22.11:
|
||||
- Fixed:
|
||||
- `Verify and repair all files` is no longer broken.
|
||||
- New feature:
|
||||
- Now `Verify and repair all files` is able to...
|
||||
- Restore if the file only in the local database.
|
||||
- Show the history.
|
||||
- Improved:
|
||||
- Performance improved.
|
||||
- 0.22.10
|
||||
- Fixed:
|
||||
- No longer unchanged hidden files and customisations are saved and transferred now.
|
||||
- File integrity of vault history indicates the integrity correctly.
|
||||
- Improved:
|
||||
- In the report, the schema of the remote database URI is now printed.
|
||||
- 0.22.9
|
||||
- Fixed:
|
||||
- Fixed a bug on `fetch chunks on demand` that could not fetch the chunks on demand.
|
||||
- Improved:
|
||||
- `fetch chunks on demand` works more smoothly.
|
||||
- Initialisation `Fetch` is now more efficient.
|
||||
- Tidied:
|
||||
- Removed some meaningless codes.
|
||||
- 0.22.8
|
||||
- Fixed:
|
||||
- Now fetch and unlock the locked remote database works well again.
|
||||
- No longer crash on symbolic links inside hidden folders.
|
||||
- Improved:
|
||||
- Chunks are now created more efficiently.
|
||||
- Splitting old notes into a larger chunk.
|
||||
- Better performance in saving notes.
|
||||
- Network activities are indicated as an icon.
|
||||
- Less memory used for binary processing.
|
||||
- Tidied:
|
||||
- Cleaned unused functions up.
|
||||
- Sorting out the codes that have become nonsense.
|
||||
- Changed:
|
||||
- Now no longer `fetch chunks on demand` needs `Pacing replication`
|
||||
- The setting `Do not pace synchronization` has been deleted.
|
||||
- 0.22.7
|
||||
- Fixed:
|
||||
- No longer deleted hidden files were ignored.
|
||||
- The document history dialogue is now able to process the deleted revisions.
|
||||
- Deletion of a hidden file is now surely performed even if the file is already conflicted.
|
||||
- 0.22.6
|
||||
- Fixed:
|
||||
- Fixed a problem with synchronisation taking a long time to start in some cases.
|
||||
- The first synchronisation after update might take a bit longer.
|
||||
- Now we can disable E2EE encryption.
|
||||
- Improved:
|
||||
- `Setup Wizard` is now more clear.
|
||||
- `Minimal Setup` is now more simple.
|
||||
- Self-hosted LiveSync now be able to use even if there are vaults with the same name.
|
||||
- Database suffix will automatically added.
|
||||
- Now Self-hosted LiveSync waits until set-up is complete.
|
||||
- Show reload prompts when possibly recommended while settings.
|
||||
- New feature:
|
||||
- A guidance dialogue prompting for settings will be shown after the installation.
|
||||
- Changed
|
||||
- `Open setup URI` is now `Use the copied setup URI`
|
||||
- `Copy setup URI` is now `Copy current settings as a new setup URI`
|
||||
- `Setup Wizard` is now `Minimal Setup`
|
||||
- `Check database configuration` is now `Check and Fix database configuration`
|
||||
- 0.22.5
|
||||
- Fixed:
|
||||
- Some description of settings have been refined
|
||||
|
||||
@@ -26,7 +26,7 @@ echo "OK!"
|
||||
|
||||
if command -v deno >/dev/null 2>&1; then
|
||||
echo "Setup finished! Also, we can set up Self-hosted LiveSync instantly, by the following setup uri."
|
||||
echo "Passphrase of setup-uri is \`welcome\`".
|
||||
echo "Passphrase of setup-uri will be printed only one time. Keep it safe!"
|
||||
echo "--- configured ---"
|
||||
echo "database : ${database}"
|
||||
echo "E2EE passphrase: ${passphrase}"
|
||||
|
||||
@@ -1,153 +1,13 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { encrypt } from "npm:octagonal-wheels@0.1.11/encryption/encryption.js";
|
||||
|
||||
const KEY_RECYCLE_COUNT = 100;
|
||||
type KeyBuffer = {
|
||||
key: CryptoKey;
|
||||
salt: Uint8Array;
|
||||
count: number;
|
||||
};
|
||||
|
||||
let semiStaticFieldBuffer: Uint8Array;
|
||||
const nonceBuffer: Uint32Array = new Uint32Array(1);
|
||||
const writeString = (string: string) => {
|
||||
// Prepare enough buffer.
|
||||
const buffer = new Uint8Array(string.length * 4);
|
||||
const length = string.length;
|
||||
let index = 0;
|
||||
let chr = 0;
|
||||
let idx = 0;
|
||||
while (idx < length) {
|
||||
chr = string.charCodeAt(idx++);
|
||||
if (chr < 128) {
|
||||
buffer[index++] = chr;
|
||||
} else if (chr < 0x800) {
|
||||
// 2 bytes
|
||||
buffer[index++] = 0xC0 | (chr >>> 6);
|
||||
buffer[index++] = 0x80 | (chr & 0x3F);
|
||||
} else if (chr < 0xD800 || chr > 0xDFFF) {
|
||||
// 3 bytes
|
||||
buffer[index++] = 0xE0 | (chr >>> 12);
|
||||
buffer[index++] = 0x80 | ((chr >>> 6) & 0x3F);
|
||||
buffer[index++] = 0x80 | (chr & 0x3F);
|
||||
} else {
|
||||
// 4 bytes - surrogate pair
|
||||
chr = (((chr - 0xD800) << 10) | (string.charCodeAt(idx++) - 0xDC00)) + 0x10000;
|
||||
buffer[index++] = 0xF0 | (chr >>> 18);
|
||||
buffer[index++] = 0x80 | ((chr >>> 12) & 0x3F);
|
||||
buffer[index++] = 0x80 | ((chr >>> 6) & 0x3F);
|
||||
buffer[index++] = 0x80 | (chr & 0x3F);
|
||||
}
|
||||
}
|
||||
return buffer.slice(0, index);
|
||||
};
|
||||
const KeyBuffs = new Map<string, KeyBuffer>();
|
||||
async function getKeyForEncrypt(passphrase: string, autoCalculateIterations: boolean): Promise<[CryptoKey, Uint8Array]> {
|
||||
// For performance, the plugin reuses the key KEY_RECYCLE_COUNT times.
|
||||
const buffKey = `${passphrase}-${autoCalculateIterations}`;
|
||||
const f = KeyBuffs.get(buffKey);
|
||||
if (f) {
|
||||
f.count--;
|
||||
if (f.count > 0) {
|
||||
return [f.key, f.salt];
|
||||
}
|
||||
f.count--;
|
||||
}
|
||||
const passphraseLen = 15 - passphrase.length;
|
||||
const iteration = autoCalculateIterations ? ((passphraseLen > 0 ? passphraseLen : 0) * 1000) + 121 - passphraseLen : 100000;
|
||||
const passphraseBin = new TextEncoder().encode(passphrase);
|
||||
const digest = await webcrypto.subtle.digest({ name: "SHA-256" }, passphraseBin);
|
||||
const keyMaterial = await webcrypto.subtle.importKey("raw", digest, { name: "PBKDF2" }, false, ["deriveKey"]);
|
||||
const salt = webcrypto.getRandomValues(new Uint8Array(16));
|
||||
const key = await webcrypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: iteration,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
KeyBuffs.set(buffKey, {
|
||||
key,
|
||||
salt,
|
||||
count: KEY_RECYCLE_COUNT,
|
||||
});
|
||||
return [key, salt];
|
||||
const noun = ["waterfall", "river", "breeze", "moon", "rain", "wind", "sea", "morning", "snow", "lake", "sunset", "pine", "shadow", "leaf", "dawn", "glitter", "forest", "hill", "cloud", "meadow", "sun", "glade", "bird", "brook", "butterfly", "bush", "dew", "dust", "field", "fire", "flower", "firefly", "feather", "grass", "haze", "mountain", "night", "pond", "darkness", "snowflake", "silence", "sound", "sky", "shape", "surf", "thunder", "violet", "water", "wildflower", "wave", "water", "resonance", "sun", "log", "dream", "cherry", "tree", "fog", "frost", "voice", "paper", "frog", "smoke", "star"];
|
||||
const adjectives = ["autumn", "hidden", "bitter", "misty", "silent", "empty", "dry", "dark", "summer", "icy", "delicate", "quiet", "white", "cool", "spring", "winter", "patient", "twilight", "dawn", "crimson", "wispy", "weathered", "blue", "billowing", "broken", "cold", "damp", "falling", "frosty", "green", "long", "late", "lingering", "bold", "little", "morning", "muddy", "old", "red", "rough", "still", "small", "sparkling", "thrumming", "shy", "wandering", "withered", "wild", "black", "young", "holy", "solitary", "fragrant", "aged", "snowy", "proud", "floral", "restless", "divine", "polished", "ancient", "purple", "lively", "nameless"];
|
||||
function friendlyString() {
|
||||
return `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${noun[Math.floor(Math.random() * noun.length)]}`;
|
||||
}
|
||||
|
||||
function getSemiStaticField(reset?: boolean) {
|
||||
// return fixed field of iv.
|
||||
if (semiStaticFieldBuffer != null && !reset) {
|
||||
return semiStaticFieldBuffer;
|
||||
}
|
||||
semiStaticFieldBuffer = webcrypto.getRandomValues(new Uint8Array(12));
|
||||
return semiStaticFieldBuffer;
|
||||
}
|
||||
const uri_passphrase = `${Deno.env.get("uri_passphrase") ?? friendlyString()}`;
|
||||
|
||||
function getNonce() {
|
||||
// This is nonce, so do not send same thing.
|
||||
nonceBuffer[0]++;
|
||||
if (nonceBuffer[0] > 10000) {
|
||||
// reset semi-static field.
|
||||
getSemiStaticField(true);
|
||||
}
|
||||
return nonceBuffer;
|
||||
}
|
||||
function arrayBufferToBase64internalBrowser(buffer: DataView | Uint8Array): Promise<string> {
|
||||
return new Promise((res, rej) => {
|
||||
const blob = new Blob([buffer], { type: "application/octet-binary" });
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (evt) {
|
||||
const dataURI = evt.target?.result?.toString() || "";
|
||||
if (buffer.byteLength != 0 && (dataURI == "" || dataURI == "data:")) return rej(new TypeError("Could not parse the encoded string"));
|
||||
const result = dataURI.substring(dataURI.indexOf(",") + 1);
|
||||
res(result);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// Map for converting hexString
|
||||
const revMap: { [key: string]: number } = {};
|
||||
const numMap: { [key: number]: string } = {};
|
||||
for (let i = 0; i < 256; i++) {
|
||||
revMap[(`00${i.toString(16)}`.slice(-2))] = i;
|
||||
numMap[i] = (`00${i.toString(16)}`.slice(-2));
|
||||
}
|
||||
|
||||
|
||||
function uint8ArrayToHexString(src: Uint8Array): string {
|
||||
return [...src].map(e => numMap[e]).join("");
|
||||
}
|
||||
|
||||
const QUANTUM = 32768;
|
||||
async function arrayBufferToBase64Single(buffer: ArrayBuffer): Promise<string> {
|
||||
const buf = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
if (buf.byteLength < QUANTUM) return btoa(String.fromCharCode.apply(null, [...buf]));
|
||||
return await arrayBufferToBase64internalBrowser(buf);
|
||||
}
|
||||
|
||||
|
||||
export async function encrypt(input: string, passphrase: string, autoCalculateIterations: boolean) {
|
||||
const [key, salt] = await getKeyForEncrypt(passphrase, autoCalculateIterations);
|
||||
// Create initial vector with semi-fixed part and incremental part
|
||||
// I think it's not good against related-key attacks.
|
||||
const fixedPart = getSemiStaticField();
|
||||
const invocationPart = getNonce();
|
||||
const iv = new Uint8Array([...fixedPart, ...new Uint8Array(invocationPart.buffer)]);
|
||||
const plainStringified = JSON.stringify(input);
|
||||
|
||||
// const plainStringBuffer: Uint8Array = tex.encode(plainStringified)
|
||||
const plainStringBuffer: Uint8Array = writeString(plainStringified);
|
||||
const encryptedDataArrayBuffer = await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plainStringBuffer);
|
||||
const encryptedData2 = (await arrayBufferToBase64Single(encryptedDataArrayBuffer));
|
||||
//return data with iv and salt.
|
||||
const ret = `["${encryptedData2}","${uint8ArrayToHexString(iv)}","${uint8ArrayToHexString(salt)}"]`;
|
||||
return ret;
|
||||
}
|
||||
|
||||
const URIBASE = "obsidian://setuplivesync?settings=";
|
||||
async function main() {
|
||||
@@ -173,8 +33,10 @@ async function main() {
|
||||
"concurrencyOfReadChunksOnline": 100,
|
||||
"minimumIntervalOfReadChunksOnline": 100,
|
||||
}
|
||||
const encryptedConf = encodeURIComponent(await encrypt(JSON.stringify(conf), "welcome", false));
|
||||
const encryptedConf = encodeURIComponent(await encrypt(JSON.stringify(conf), uri_passphrase, false));
|
||||
const theURI = `${URIBASE}${encryptedConf}`;
|
||||
console.log(theURI);
|
||||
console.log("\nYour passphrase of Setup-URI is: ", uri_passphrase);
|
||||
console.log("This passphrase is never shown again, so please note it in a safe place.")
|
||||
}
|
||||
await main();
|
||||
@@ -129,12 +129,15 @@ curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to young-
|
||||
<-- Configuring CouchDB by REST APIs Done!
|
||||
OK!
|
||||
Setup finished! Also, we can set up Self-hosted LiveSync instantly, by the following setup uri.
|
||||
Passphrase of setup-uri is `welcome`.
|
||||
Passphrase of setup-uri will be printed only one time. Keep it safe!
|
||||
--- configured ---
|
||||
database : obsidiannotes
|
||||
E2EE passphrase: dark-wildflower-26467
|
||||
--- setup uri ---
|
||||
obsidian://setuplivesync?settings=%5B%22gZkBwjFbLqxbdSIbJymU%2FmTPBPAKUiHVGDRKYiNnKhW0auQeBgJOfvnxexZtMCn8sNiIUTAlxNaMGF2t%2BCEhpJoeCP%2FO%2BrwfN5LaNDQyky1Uf7E%2B64A5UWyjOYvZDOgq4iCKSdBAXp9oO%2BwKh4MQjUZ78vIVvJp8Mo6NWHfm5fkiWoAoddki1xBMvi%2BmmN%2FhZatQGcslVb9oyYWpZocduTl0a5Dv%2FQviGwlYQ%2F4NY0dVDIoOdvaYS%2FX4GhNAnLzyJKMXhPEJHo9FvR%2FEOBuwyfMdftV1SQUZ8YDCuiR3T7fh7Kn1c6OFgaFMpFm%2BWgIJ%2FZpmAyhZFpEcjpd7ty%2BN9kfd9gQsZM4%2BYyU9OwDd2DahVMBWkqoV12QIJ8OlJScHHdcUfMW5ex%2F4UZTWKNEHJsigITXBrtq11qGk3rBfHys8O0vY6sz%2FaYNM3iAOsR1aoZGyvwZm4O6VwtzK8edg0T15TL4O%2B7UajQgtCGxgKNYxb8EMOGeskv7NifYhjCWcveeTYOJzBhnIDyRbYaWbkAXQgHPBxzJRkkG%2FpBPfBBoJarj7wgjMvhLJ9xtL4FbP6sBNlr8jtAUCoq4L7LJcRNF4hlgvjJpL2BpFZMzkRNtUBcsRYR5J%2BM1X2buWi2BHncbSiRRDKEwNOQkc%2FmhMJjbAn%2F8eNKRuIICOLD5OvxD7FZNCJ0R%2BWzgrzcNV%22%2C%22ec7edc900516b4fcedb4c7cc01000000%22%2C%22fceb5fe54f6619ee266ed9a887634e07%22%5D
|
||||
|
||||
Your passphrase of Setup-URI is: patient-haze
|
||||
This passphrase is never shown again, so please note it in a safe place.
|
||||
```
|
||||
|
||||
All we have to do is copy the setup-URI (`obsidian`://...`) and open it from Self-hosted LiveSync on Obsidian.
|
||||
|
||||
Reference in New Issue
Block a user