diff --git a/.eslintrc b/.eslintrc index 6a8172a..08e619d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -23,12 +23,14 @@ "args": "none" } ], + "no-unused-labels": "off", "@typescript-eslint/ban-ts-comment": "off", "no-prototype-builtins": "off", "@typescript-eslint/no-empty-function": "off", "require-await": "warn", "no-async-promise-executor": "off", "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unnecessary-type-assertion": "error" + "@typescript-eslint/no-unnecessary-type-assertion": "error", + "no-constant-condition": ["error", { "checkLoops": false }] + } } -} \ No newline at end of file diff --git a/docs/settings.md b/docs/settings.md index cc15dd0..c065c2e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1,267 +1,750 @@ -NOTE: This document surely became outdated. I'll improve this doc in a while. but your contributions are always welcome. +NOTE: This document not completed. I'll improve this doc in a while. but your contributions are always welcome. -# Settings of this plugin +# Settings of Self-hosted LiveSync -The settings dialog has been quite long, so I split each configuration into tabs. -If you feel something, please feel free to inform me. +There are many settings in Self-hosted LiveSync. This document describes each setting in detail (not how-to). Configuration and settings are divided into several categories and indicated by icons. The icon is as follows: -| icon | description | -| :---: | ----------------------------------------------------------------- | -| πŸ›°οΈ | [Remote Database Configurations](#remote-database-configurations) | -| πŸ“¦ | [Local Database Configurations](#local-database-configurations) | -| βš™οΈ | [General Settings](#general-settings) | -| πŸ” | [Sync Settings](#sync-settings) | -| πŸ”§ | [Miscellaneous](#miscellaneous) | -| 🧰 | [Hatch](#miscellaneous) | -| πŸ”Œ | [Plugin and its settings](#plugin-and-its-settings) | -| πŸš‘ | [Corrupted data](#corrupted-data) | +| Icon | Description | +| :--: | ------------------------------------------------------------------ | +| πŸ’¬ | [0. Update Information](#0-update-information) | +| πŸ§™β€β™‚οΈ | [1. Setup](#1-setup) | +| βš™οΈ | [2. General Settings](#2-general-settings) | +| πŸ›°οΈ | [3. Remote Configuration](#3-remote-configuration) | +| πŸ”„ | [4. Sync Settings](#4-sync-settings) | +| 🚦 | [5. Selector (Advanced)](#5-selector-advanced) | +| πŸ”Œ | [6. Customization sync (Advanced)](#6-customization-sync-advanced) | +| 🧰 | [7. Hatch](#7-hatch) | +| πŸ”§ | [8. Advanced (Advanced)](#8-advanced-advanced) | +| πŸ’ͺ | [9. Power users (Power User)](#9-power-users-power-user) | +| 🩹 | [10. Patches (Edge Case)](#10-patches-edge-case) | +| πŸŽ›οΈ | [11. Maintenance](#11-maintenance) | -## Remote Database Configurations -Configure the settings of synchronize server. If any synchronization is enabled, you can't edit this section. Please disable all synchronization to change. +## 0. Update Information -### URI -URI of CouchDB. In the case of Cloudant, It's "External Endpoint(preferred)". -**Do not end it up with a slash** when it doesn't contain the database name. +This pane shows version up information. You can check what has been changed in recent versions. -### Username -Your CouchDB's Username. Administrator's privilege is preferred. +## 1. Setup -### Password -Your CouchDB's Password. -Note: This password is saved into your Obsidian's vault in plain text. +This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync. -### Database Name -The Database name to synchronize. -⚠️If not exist, created automatically. +### 1. Quick Setup +Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted LiveSync with a few clicks. -### End to End Encryption -Encrypt your database. It affects only the database, your files are left as plain. +#### Use the copied setup URI -The encryption algorithm is AES-GCM. +Setup the Self-hosted LiveSync with the `setup URI` which is [copied from another device](#copy-current-settings-as-a-new-setup-uri) or the setup script. -Note: If you want to use "Plugins and their settings", you have to enable this. +#### Minimal setup -### Passphrase -The passphrase to used as the key of encryption. Please use the long text. +Step-by-step setup for Self-hosted LiveSync. You can setup Self-hosted LiveSync manually with Minimal setting items. -### Apply -Set the End to End encryption enabled and its passphrase for use in replication. -If you change the passphrase of an existing database, overwriting the remote database is strongly recommended. +#### Enable LiveSync on this device as the setup was completed manually +This button only appears when the setup was not completed. If you have completed the setup manually, you can enable LiveSync on this device by this button. -### Overwrite remote database -Overwrite the remote database with the local database using the passphrase you applied. +### 2. To setup the other devices +#### Copy current settings as a new setup URI -### Rebuild -Rebuild remote and local databases with local files. It will delete all document history and retained chunks, and shrink the database. +You can copy the current settings as a new setup URI. And this URI can be used to setup the other devices as [Use the copied setup URI](#use-the-copied-setup-uri). -### Test Database connection -You can check the connection by clicking this button. +### 3. Reset -### Check database configuration -You can check and modify your CouchDB configuration from here directly. +#### Discard existing settings and databases -### Lock remote database. -Other devices are banned from the database when you have locked the database. -If you have something troubled with other devices, you can protect the vault and remote database with your device. +Reset the Self-hosted LiveSync settings and databases. +**Hazardous operation. Please be careful when using this.** -## Local Database Configurations -"Local Database" is created inside your obsidian. +### 4. Enable extra and advanced features -### Batch database update -Delay database update until raise replication, open another file, window visibility changes, or file events except for file modification. -This option can not be used with LiveSync at the same time. +To keep the set-up dialogue simple, some panes are hidden in default. You can enable them here. +#### Enable advanced features -### Fetch rebuilt DB. -If one device rebuilds or locks the remote database, every other device will be locked out from the remote database until it fetches rebuilt DB. +Setting key: useAdvancedMode -### minimum chunk size and LongLine threshold -The configuration of chunk splitting. +Following panes will be shown when you enable this setting. +| Icon | Description | +| :--: | ------------------------------------------------------------------ | +| 🚦 | [5. Selector (Advanced)](#5-selector-advanced) | +| πŸ”Œ | [6. Customization sync (Advanced)](#6-customization-sync-advanced) | +| πŸ”§ | [8. Advanced (Advanced)](#8-advanced-advanced) | -Self-hosted LiveSync splits the note into chunks for efficient synchronization. This chunk should be longer than the "Minimum chunk size". +#### Enable power user features -Specifically, the length of the chunk is determined by the following orders. +Setting key: usePowerUserMode -1. Find the nearest newline character, and if it is farther than LongLineThreshold, this piece becomes an independent chunk. +Following panes will be shown when you enable this setting. +| Icon | Description | +| :--: | ------------------------------------------------------------------ | +| πŸ’ͺ | [9. Power users (Power User)](#9-power-users-power-user) | -2. If not, find the nearest to these items. - 1. A newline character - 2. An empty line (Windows style) - 3. An empty line (non-Windows style) -3. Compare the farther in these 3 positions and the next "newline\]#" position, and pick a shorter piece as a chunk. +#### Enable edge case treatment features -This rule was made empirically from my dataset. If this rule acts as badly on your data. Please give me the information. +Setting key: useEdgeCaseMode -You can dump saved note structure to `Dump informations of this doc`. Replace every character with x except newline and "#" when sending information to me. +Following panes will be shown when you enable this setting. +| Icon | Description | +| :--: | ------------------------------------------------------------------ | +| 🩹 | [10. Patches (Edge Case)](#10-patches-edge-case) | -The default values are 20 letters and 250 letters. +## 2. General Settings -## General Settings +### 1. Appearance -### Do not show low-priority log -If you enable this option, log only the entries with the popup. +#### Display Language -### Verbose log +Setting key: displayLanguage -## Sync Settings +You can change the display language. It is independent of the system language and/or Obsidian's language. +Note: Not all messages have been translated. And, please revert to "Default" when reporting errors. Of course, your contribution to translation is always welcome! -### LiveSync -Do LiveSync. +#### Show status inside the editor -It is the one of raison d'Γͺtre of this plugin. +Setting key: showStatusOnEditor -Useful, but this method drains many batteries on the mobile and uses not the ignorable amount of data transfer. +We can show the status of synchronisation inside the editor. -This method is exclusive to other synchronization methods. +Reflected after reboot -### Periodic Sync -Synchronize periodically. +#### Show status as icons only -### Periodic Sync Interval -Unit is seconds. +Setting key: showOnlyIconsOnEditor -### Sync on Save -Synchronize when the note has been modified or created. +Show status as icons only. This is useful when you want to save space on the status bar. -### Sync on File Open -Synchronize when the note is opened. +#### Show status on the status bar -### Sync on Start -Synchronize when Obsidian started. +Setting key: showStatusOnStatusbar -### Use Trash for deleted files -When the file has been deleted on remote devices, deletion will be replicated to the local device and the file will be deleted. +We can show the status of synchronisation on the status bar. (Default: On) -If this option is enabled, move deleted files into the trash instead delete actually. +### 2. Logging -### Do not delete empty folder -Self-hosted LiveSync will delete the folder when the folder becomes empty. If this option is enabled, leave it as an empty folder. +#### Show only notifications -### Use newer file if conflicted (beta) -Always use the newer file to resolve and overwrite when conflict has occurred. +Setting key: lessInformationInLog +Prevent logging and show only notification. Please disable when you report the logs -### Experimental. -### Sync hidden files +#### Verbose Log -Synchronize hidden files. +Setting key: showVerboseLog -- Scan hidden files before replication. -If you enable this option, all hidden files are scanned once before replication. +Show verbose log. Please enable when you report the logs -- Scan hidden files periodicaly. -If you enable this option, all hidden files will be scanned each [n] seconds. +## 3. Remote Configuration -Hidden files are not actively detected, so we need scanning. +### 1. Remote Server -Each scan stores the file with their modification time. And if the file has been disappeared, the fact is also stored. Then, When the entry of the hidden file has been replicated, it will be reflected in the storage if the entry is newer than storage. +#### Remote Type -Therefore, the clock must be adjusted. If the modification time is determined to be older, the changeset will be skipped or cancelled (It means, **deleted**), even if the file spawned in a hidden folder. +Setting key: remoteType -### Advanced settings -Self-hosted LiveSync using PouchDB and synchronizes with the remote by [this protocol](https://docs.couchdb.org/en/stable/replication/protocol.html). -So, it splits every entry into chunks to be acceptable by the database with limited payload size and document size. +Remote server type -However, it was not enough. -According to [2.4.2.5.2. Upload Batch of Changed Documents](https://docs.couchdb.org/en/stable/replication/protocol.html#upload-batch-of-changed-documents) in [Replicate Changes](https://docs.couchdb.org/en/stable/replication/protocol.html#replicate-changes), it might become a bigger request. +### 2. Notification -Unfortunately, there is no way to deal with this automatically by size for every request. -Therefore, I made it possible to configure this. +#### Notify when the estimated remote storage size exceeds on start up -Note: If you set these values lower number, the number of requests will increase. -Therefore, if you are far from the server, the total throughput will be low, and the traffic will increase. +Setting key: notifyThresholdOfRemoteStorageSize -### Batch size -Number of change feed items to process at a time. Defaults to 250. +MB (0 to disable). We can get a notification when the estimated remote storage size exceeds this value. -### Batch limit -Number of batches to process at a time. Defaults to 40. This along with batch size controls how many docs are kept in memory at a time. +### 3. Confidentiality -## Miscellaneous +#### End-to-End Encryption -### Show status inside editor -Show information inside the editor pane. -It would be useful for mobile. +Setting key: encrypt -### Check integrity on saving -Check all chunks are correctly saved on saving. +Enable end-to-end encryption. enabling this is recommend. If you change the passphrase, you need to rebuild databases (You will be informed). -### Presets -You can set synchronization method at once as these pattern: -- LiveSync - - LiveSync : enabled - - Batch database update : disabled - - Periodic Sync : disabled - - Sync on Save : disabled - - Sync on File Open : disabled - - Sync on Start : disabled -- Periodic w/ batch - - LiveSync : disabled - - Batch database update : enabled - - Periodic Sync : enabled - - Sync on Save : disabled - - Sync on File Open : enabled - - Sync on Start : enabled -- Disable all sync - - LiveSync : disabled - - Batch database update : disabled - - Periodic Sync : disabled - - Sync on Save : disabled - - Sync on File Open : disabled - - Sync on Start : disabled +#### Passphrase +Setting key: passphrase -## Hatch -From here, everything is under the hood. Please handle it with care. +Encrypting passphrase. If you change the passphrase, you need to rebuild databases (You will be informed). -When there are problems with synchronization, the warning message is shown Under this section header. +#### Path Obfuscation -- Pattern 1 -![CorruptedData](../images/lock_pattern1.png) -This message is shown when the remote database is locked and your device is not marked as "resolved". -Almost it is happened by enabling End-to-End encryption or History has been dropped. -If you enabled End-to-End encryption, you can unlock the remote database by "Apply and receive" automatically. Or "Drop and receive" when you dropped. If you want to unlock manually, click "mark this device as resolved". +Setting key: usePathObfuscation -- Pattern 2 -![CorruptedData](../images/lock_pattern2.png) -The remote database indicates that has been unlocked Pattern 1. -When you mark all devices as resolved, you can unlock the database. -But, there's no problem even if you leave it as it is. +In default, the path of the file is not obfuscated to improve the performance. If you enable this, the path of the file will be obfuscated. This is useful when you want to hide the path of the file. -### Verify and repair all files -read all files in the vault, and update them into the database if there's diff or could not read from the database. +#### Use dynamic iteration count (Experimental) -### Suspend file watching -If enable this option, Self-hosted LiveSync dismisses every file change or deletes the event. +Setting key: useDynamicIterationCount -From here, these commands are used inside applying encryption passphrases or dropping histories. +This is an experimental feature and not recommended. If you enable this, the iteration count of the encryption will be dynamically determined. This is useful when you want to improve the performance. -Usually, doesn't use it so much. But sometimes it could be handy. +--- -## Plugins and settings (beta) +**now writing from here onwards, sorry** -### Enable plugin synchronization -If you want to use this feature, you have to activate this feature by this switch. +--- -### Sweep plugins automatically -Plugin sweep will run before replication automatically. +### 4. Minio,S3,R2 -### Sweep plugins periodically -Plugin sweep will run each 1 minute. +#### Endpoint URL -### Notify updates -When replication is complete, a message will be notified if a newer version of the plugin applied to this device is configured on another device. +Setting key: endpoint -### Device and Vault name -To save the plugins, you have to set a unique name every each device. +#### Access Key -### Open -Open the "Plugins and their settings" dialog. +Setting key: accessKey -### Corrupted or missing data -![CorruptedData](../images/corrupted_data.png) +#### Secret Key -When Self-hosted LiveSync could not write to the file on the storage, the files are shown here. If you have the old data in your vault, change it once, it will be cured. Or you can use the "File History" plugin. +Setting key: secretKey + +#### Region + +Setting key: region + +#### Bucket Name + +Setting key: bucket + +#### Use Custom HTTP Handler + +Setting key: useCustomRequestHandler +If your Object Storage could not configured accepting CORS, enable this. + +#### Test Connection + +#### Apply Settings + +### 5. CouchDB + +#### URI + +Setting key: couchDB_URI + +#### Username + +Setting key: couchDB_USER +username + +#### Password + +Setting key: couchDB_PASSWORD +password + +#### Database name + +Setting key: couchDB_DBNAME + +#### Test Database Connection + +Open database connection. If the remote database is not found and you have the privilege to create a database, the database will be created. + +#### Check and fix database configuration + +Check the database configuration, and fix if there are any problems. + +#### Apply Settings + +## 4. Sync Settings + +### 1. Synchronization Preset + +#### Presets + +Setting key: preset +Apply preset configuration + +### 2. Synchronization Methods + +#### Sync Mode + +Setting key: syncMode + +#### Periodic Sync interval + +Setting key: periodicReplicationInterval +Interval (sec) + +#### Sync on Save + +Setting key: syncOnSave +When you save a file, sync automatically + +#### Sync on Editor Save + +Setting key: syncOnEditorSave +When you save a file in the editor, sync automatically + +#### Sync on File Open + +Setting key: syncOnFileOpen +When you open a file, sync automatically + +#### Sync on Start + +Setting key: syncOnStart +Start synchronization after launching Obsidian. + +#### Sync after merging file + +Setting key: syncAfterMerge +Sync automatically after merging files + +### 3. Update thinning + +#### Batch database update + +Setting key: batchSave +Reducing the frequency with which on-disk changes are reflected into the DB + +#### Minimum delay for batch database updating + +Setting key: batchSaveMinimumDelay +Seconds. Saving to the local database will be delayed until this value after we stop typing or saving. + +#### Maximum delay for batch database updating + +Setting key: batchSaveMaximumDelay +Saving will be performed forcefully after this number of seconds. + +### 4. Deletion Propagation (Advanced) + +#### Use the trash bin + +Setting key: trashInsteadDelete +Do not delete files that are deleted in remote, just move to trash. + +#### Keep empty folder + +Setting key: doNotDeleteFolder +Normally, a folder is deleted when it becomes empty after a synchronization. Enabling this will prevent it from getting deleted + +### 5. Conflict resolution (Advanced) + +#### Always overwrite with a newer file (beta) + +Setting key: resolveConflictsByNewerFile +(Def off) Resolve conflicts by newer files automatically. + +#### Postpone resolution of inactive files + +Setting key: checkConflictOnlyOnOpen + +#### Postpone manual resolution of inactive files + +Setting key: showMergeDialogOnlyOnActive + +### 6. Sync settings via markdown (Advanced) + +#### Filename + +Setting key: settingSyncFile +If you set this, all settings are saved in a markdown file. You will be notified when new settings arrive. You can set different files by the platform. + +#### Write credentials in the file + +Setting key: writeCredentialsForSettingSync +(Not recommended) If set, credentials will be stored in the file. + +#### Notify all setting files + +Setting key: notifyAllSettingSyncFile + +### 7. Hidden files (Advanced) + +#### Hidden file synchronization + +#### Enable Hidden files sync + +#### Scan for hidden files before replication + +Setting key: syncInternalFilesBeforeReplication + +#### Scan hidden files periodically + +Setting key: syncInternalFilesInterval +Seconds, 0 to disable + +## 5. Selector (Advanced) + +### 1. Normal Files + +#### Synchronising files + +(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files. + +#### Non-Synchronising files + +(RegExp) If this is set, any changes to local and remote files that match this will be skipped. + +#### Maximum file size + +Setting key: syncMaxSizeInMB +(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. + +#### (Beta) Use ignore files + +Setting key: useIgnoreFiles +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. + +#### Ignore files + +Setting key: ignoreFiles +We can use multiple ignore files, e.g.) `.gitignore, .dockerignore` + +### 2. Hidden Files (Advanced) + +#### Ignore patterns + +#### Add default patterns + +## 6. Customization sync (Advanced) + +### 1. Customization Sync + +#### Device name + +Setting key: deviceAndVaultName +Unique name between all synchronized devices. To edit this setting, please disable customization sync once. + +#### Per-file-saved customization sync + +Setting key: usePluginSyncV2 +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. + +#### Enable customization sync + +Setting key: usePluginSync + +#### Scan customization automatically + +Setting key: autoSweepPlugins +Scan customization before replicating. + +#### Scan customization periodically + +Setting key: autoSweepPluginsPeriodic +Scan customization every 1 minute. + +#### Notify customized + +Setting key: notifyPluginOrSettingUpdated +Notify when other device has newly customized. + +#### Open + +Open the dialog + +## 7. Hatch + +### 1. Reporting Issue + +#### Make report to inform the issue + +#### Write logs into the file + +Setting key: writeLogToTheFile +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. + +### 2. Scram Switches + +#### Suspend file watching + +Setting key: suspendFileWatching +Stop watching for file change. + +#### Suspend database reflecting + +Setting key: suspendParseReplicationResult +Stop reflecting database changes to storage files. + +### 3. Recovery and Repair + +#### Recreate missing chunks for all files + +This will recreate chunks for all files. If there were missing chunks, this may fix the errors. + +#### Verify and repair all files + +Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep. + +#### Check and convert non-path-obfuscated files + +### 4. Reset + +#### Back to non-configured + +#### Delete all customization sync data + +## 8. Advanced (Advanced) + +### 1. Memory cache + +#### Memory cache size (by total items) + +Setting key: hashCacheMaxCount + +#### Memory cache size (by total characters) + +Setting key: hashCacheMaxAmount +(Mega chars) + +### 2. Local Database Tweak + +#### Enhance chunk size + +Setting key: customChunkSize + +#### Use splitting-limit-capped chunk splitter + +Setting key: enableChunkSplitterV2 +If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker. + +#### Use Segmented-splitter + +Setting key: useSegmenter +If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature. + +### 3. Transfer Tweak + +#### Fetch chunks on demand + +Setting key: readChunksOnline +(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. + +#### Batch size of on-demand fetching + +Setting key: concurrencyOfReadChunksOnline + +#### The delay for consecutive on-demand fetches + +Setting key: minimumIntervalOfReadChunksOnline + +#### Send chunks in bulk + +Setting key: sendChunksBulk +If this enabled, all chunks will be sent in bulk. This is useful for the environment that has a high latency. + +#### Maximum size of chunks to send in one request + +Setting key: sendChunksBulkMaxSize +MB + +## 9. Power users (Power User) + +### 1. Remote Database Tweak + +#### Incubate Chunks in Document (Beta) + +Setting key: useEden +If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised. + +#### Maximum Incubating Chunks + +Setting key: maxChunksInEden +The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks. + +#### Maximum Incubating Chunk Size + +Setting key: maxTotalLengthInEden +The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks. + +#### Maximum Incubation Period + +Setting key: maxAgeInEden +The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks. + +#### Data Compression (Experimental) + +Setting key: enableCompression + +### 2. CouchDB Connection Tweak + +#### Batch size + +Setting key: batch_size +Number of change feed items to process at a time. Defaults to 50. Minimum is 2. + +#### Batch limit + +Setting key: batches_limit +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. + +#### Use timeouts instead of heartbeats + +Setting key: useTimeouts +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. + +### 3. Configuration Encryption + +#### Encrypting sensitive configuration items + +Setting key: configPassphraseStore + +#### Passphrase of sensitive configuration items + +Setting key: configPassphrase +This passphrase will not be copied to another device. It will be set to `Default` until you configure it again. + +## 10. Patches (Edge Case) + +### 1. Compatibility (Metadata) + +#### Do not keep metadata of deleted files. + +Setting key: deleteMetadataOfDeletedFiles + +#### Delete old metadata of deleted files on start-up + +Setting key: automaticallyDeleteMetadataOfDeletedFiles +(Days passed, 0 to disable automatic-deletion) + +### 2. Compatibility (Conflict Behaviour) + +#### Always resolve conflicts manually + +Setting key: disableMarkdownAutoMerge +If this switch is turned on, a merge dialog will be displayed, even if the sensible-merge is possible automatically. (Turn on to previous behavior) + +#### Always reflect synchronized changes even if the note has a conflict + +Setting key: writeDocumentsIfConflicted +Turn on to previous behavior + +### 3. Compatibility (Database structure) + +#### (Obsolete) Use an old adapter for compatibility (obsolete) + +Setting key: useIndexedDBAdapter +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. + +#### Compute revisions for chunks (Previous behaviour) + +Setting key: doNotUseFixedRevisionForChunks +If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour) + +#### Handle files as Case-Sensitive + +Setting key: handleFilenameCaseSensitive +If this enabled, All files are handled as case-Sensitive (Previous behaviour). + +### 4. Compatibility (Internal API Usage) + +#### Scan changes on customization sync + +Setting key: watchInternalFileChanges +Do not use internal API + +### 5. Edge case addressing (Database) + +#### Database suffix + +Setting key: additionalSuffixOfDatabaseName +LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured. + +#### The Hash algorithm for chunk IDs (Experimental) + +Setting key: hashAlg + +### 6. Edge case addressing (Behaviour) + +#### Fetch database with previous behaviour + +Setting key: doNotSuspendOnFetching + +#### Keep empty folder + +Setting key: doNotDeleteFolder +Normally, a folder is deleted when it becomes empty after a synchronization. Enabling this will prevent it from getting deleted + +### 7. Edge case addressing (Processing) + +#### Do not split chunks in the background + +Setting key: disableWorkerForGeneratingChunks +If disabled(toggled), chunks will be split on the UI thread (Previous behaviour). + +#### Process small files in the foreground + +Setting key: processSmallFilesInUIThread +If enabled, the file under 1kb will be processed in the UI thread. + +### 8. Compatibility (Trouble addressed) + +#### Do not check configuration mismatch before replication + +Setting key: disableCheckingConfigMismatch + +## 11. Maintenance + +### 1. Scram! + +#### Lock remote + +Lock remote to prevent synchronization with other devices. + +#### Emergency restart + +place the flag file to prevent all operation and restart. + +### 2. Data-complementary Operations + +#### Resend + +Resend all chunks to the remote. + +#### Reset journal received history + +Initialise journal received history. On the next sync, every item except this device sent will be downloaded again. + +#### Reset journal sent history + +Initialise journal sent history. On the next sync, every item except this device received will be sent again. + +### 3. Rebuilding Operations (Local) + +#### Fetch from remote + +Restore or reconstruct local database from remote. + +#### Fetch rebuilt DB (Save local documents before) + +Restore or reconstruct local database from remote database but use local chunks. + +### 4. Total Overhaul + +#### Rebuild everything + +Rebuild local and remote database with local files. + +### 5. Rebuilding Operations (Remote Only) + +#### Perform compaction + +Compaction discards all of Eden in the non-latest revisions, reducing the storage usage. However, this operation requires the same free space on the remote as the current database. + +#### Overwrite remote + +Overwrite remote with local DB and passphrase. + +#### Reset all journal counter + +Initialise all journal history, On the next sync, every item will be received and sent. + +#### Purge all journal counter + +Purge all sending and downloading cache. + +#### Make empty the bucket + +Delete all data on the remote. + +### 6. Niches + +#### (Obsolete) Clean up databases + +Delete unused chunks to shrink the database. However, this feature could be not effective in some cases. Please use rebuild everything instead. + +### 7. Reset + +#### Discard local database to reset or uninstall Self-hosted LiveSync diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 2824175..1bfcd9e 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -9,67 +9,10 @@ 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 -*/ -`; +import { terserOption } from "./terser.config.mjs"; const prod = process.argv[2] === "production"; -const dev = process.argv[2] === "dev"; - -const keepTest = !prod || dev; - -const terserOpt = { - sourceMap: !prod - ? { - url: "inline", - } - : {}, - format: { - indent_level: 2, - beautify: true, - comments: "some", - ecma: 2018, - preamble: banner, - webkit: true, - }, - parse: { - // parse options - }, - compress: { - // compress options - defaults: false, - evaluate: true, - dead_code: true, - inline: 3, - join_vars: true, - loops: true, - passes: prod ? 4 : 1, - reduce_vars: true, - reduce_funcs: true, - arrows: true, - collapse_vars: true, - comparisons: true, - lhs_constants: true, - hoist_props: true, - side_effects: true, - if_return: true, - ecma: 2018, - unused: true, - }, - // mangle: false, - - ecma: 2018, // specify one of: 5, 2015, 2016, etc. - enclose: false, // or specify true, or "args:values" - keep_classnames: true, - keep_fnames: true, - ie8: false, - module: false, - // nameCache: null, // or specify a name cache object - safari10: false, - toplevel: false, -}; +const keepTest = !prod; const manifestJson = JSON.parse(fs.readFileSync("./manifest.json") + ""); const packageJson = JSON.parse(fs.readFileSync("./package.json") + ""); @@ -91,7 +34,7 @@ const plugins = [ console.log("Performing terser"); const src = fs.readFileSync("./main_org.js").toString(); // @ts-ignore - const ret = await minify(src, terserOpt); + const ret = await minify(src, terserOption); if (ret && ret.code) { fs.writeFileSync("./main.js", ret.code); } @@ -107,7 +50,7 @@ const plugins = [ const externals = ["obsidian", "electron", "crypto", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr"]; const context = await esbuild.context({ banner: { - js: banner, + js: "// Leave it all to terser", }, entryPoints: ["src/main.ts"], bundle: true, @@ -146,7 +89,7 @@ const context = await esbuild.context({ ], }); -if (prod || dev) { +if (prod) { await context.rebuild(); process.exit(0); } else { diff --git a/package-lock.json b/package-lock.json index 66ae060..11113e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "fflate": "^0.8.2", "idb": "^8.0.0", "minimatch": "^10.0.1", - "octagonal-wheels": "^0.1.14", + "octagonal-wheels": "^0.1.15", "xxhash-wasm": "0.4.2", "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" }, @@ -5099,10 +5099,9 @@ } }, "node_modules/octagonal-wheels": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.14.tgz", - "integrity": "sha512-W0DQL5YNL7oJH2Dcb7mOE/P7MNrM7PXWJZHJpPGzbrKHAT14OtklbBFNJvM26v8nizwlb5WHQA+W/Tg1CIDSGQ==", - "license": "MIT", + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.15.tgz", + "integrity": "sha512-rl6y/3/+tqeiDmBmW2RgYQh85D4xVHQIGg14DyC4/sUnO6UDLuVUULIO+00E2jfA/o4coKnPOPEB8Tt6BE9SEA==", "dependencies": { "idb": "^8.0.0", "xxhash-wasm": "0.4.2", @@ -10234,9 +10233,9 @@ } }, "octagonal-wheels": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.14.tgz", - "integrity": "sha512-W0DQL5YNL7oJH2Dcb7mOE/P7MNrM7PXWJZHJpPGzbrKHAT14OtklbBFNJvM26v8nizwlb5WHQA+W/Tg1CIDSGQ==", + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.15.tgz", + "integrity": "sha512-rl6y/3/+tqeiDmBmW2RgYQh85D4xVHQIGg14DyC4/sUnO6UDLuVUULIO+00E2jfA/o4coKnPOPEB8Tt6BE9SEA==", "requires": { "idb": "^8.0.0", "xxhash-wasm": "0.4.2", diff --git a/package.json b/package.json index 89d8be6..3a7d0e2 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "fflate": "^0.8.2", "idb": "^8.0.0", "minimatch": "^10.0.1", - "octagonal-wheels": "^0.1.14", + "octagonal-wheels": "^0.1.15", "xxhash-wasm": "0.4.2", "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" } diff --git a/src/common/utils.ts b/src/common/utils.ts index 36de1c1..38cc920 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -134,7 +134,13 @@ export function generatePatchObj(from: Record, to //if type is not match, replace completely. ret[key] = { [MARK_SWAPPED]: value }; } else { - if (typeof (v) == "object" && typeof (value) == "object" && !Array.isArray(v) && !Array.isArray(value)) { + if (v === null && value === null) { + // NO OP. + } else if (v === null && value !== null) { + ret[key] = { [MARK_SWAPPED]: value }; + } else if (v !== null && value === null) { + ret[key] = { [MARK_SWAPPED]: value }; + } else if (typeof (v) == "object" && typeof (value) == "object" && !Array.isArray(v) && !Array.isArray(value)) { const wk = generatePatchObj(v, value); if (Object.keys(wk).length > 0) ret[key] = wk; } else if (typeof (v) == "object" && typeof (value) == "object" && Array.isArray(v) && Array.isArray(value)) { @@ -169,6 +175,10 @@ export function applyPatch(from: Record, patch: R delete ret[key]; continue; } + if (value === null) { + ret[key] = null; + continue; + } if (typeof (value) == "object") { if (MARK_SWAPPED in value) { ret[key] = value[MARK_SWAPPED]; @@ -251,6 +261,7 @@ export function mergeObject( export function flattenObject(obj: Record, path: string[] = []): [string, any][] { if (typeof (obj) != "object") return [[path.join("."), obj]]; + if (obj === null) return [[path.join("."), null]]; if (Array.isArray(obj)) return [[path.join("."), JSON.stringify(obj)]]; const e = Object.entries(obj); const ret = [] @@ -489,6 +500,35 @@ export function useMemo({ key, forceUpdate, validator }: MemoOption, updateFu return value; } +// const _static = new Map(); +const _staticObj = new Map(); + +export function useStatic(key: string): { value: (T | undefined) }; +export function useStatic(key: string, initial: T): { value: T }; +export function useStatic(key: string, initial?: T) { + // if (!_static.has(key) && initial) { + // _static.set(key, initial); + // } + const obj = _staticObj.get(key); + if (obj !== undefined) { + return obj; + } else { + // let buf = initial; + const obj = { + _buf: initial, + get value() { + return this._buf as T; + }, + set value(value: T) { + this._buf = value + } + } + _staticObj.set(key, obj); + return obj; + } +} export function disposeMemo(key: string) { _cached.delete(key); } diff --git a/src/features/CmdStatusInsideEditor.ts b/src/features/CmdStatusInsideEditor.ts index bf9980e..978ffeb 100644 --- a/src/features/CmdStatusInsideEditor.ts +++ b/src/features/CmdStatusInsideEditor.ts @@ -129,7 +129,7 @@ export class LogAddOn extends LiveSyncCommands { this.statusBarLabels = statusBarLabels; const applyToDisplay = throttle((label: typeof statusBarLabels.value) => { - const v = label; + // const v = label; this.applyStatusBarText(); }, 20); @@ -161,10 +161,10 @@ export class LogAddOn extends LiveSyncCommands { } onload(): void | Promise { - eventHub.on(EVENT_FILE_RENAMED, (evt: CustomEvent<{ oldPath: string, newPath: string }>) => { + eventHub.onEvent(EVENT_FILE_RENAMED, (evt: CustomEvent<{ oldPath: string, newPath: string }>) => { this.setFileStatus(); }); - eventHub.on(EVENT_LEAF_ACTIVE_CHANGED, () => this.onActiveLeafChange()); + eventHub.onEvent(EVENT_LEAF_ACTIVE_CHANGED, () => this.onActiveLeafChange()); const w = document.querySelectorAll(`.livesync-status`); w.forEach(e => e.remove()); @@ -175,7 +175,7 @@ export class LogAddOn extends LiveSyncCommands { this.messageArea = this.statusDiv.createDiv({ cls: "livesync-status-messagearea" }); this.logMessage = this.statusDiv.createDiv({ cls: "livesync-status-logmessage" }); this.logHistory = this.statusDiv.createDiv({ cls: "livesync-status-loghistory" }); - eventHub.on(EVENT_LAYOUT_READY, () => this.adjustStatusDivPosition()); + eventHub.onEvent(EVENT_LAYOUT_READY, () => this.adjustStatusDivPosition()); if (this.settings.showStatusOnStatusbar) { this.statusBar = this.plugin.addStatusBarItem(); this.statusBar.addClass("syncstatusbar"); diff --git a/src/lib b/src/lib index 633af44..3108e3e 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 633af447d24339718d0f6e16e950a124b46b3896 +Subproject commit 3108e3e3dba518d4dd85c18982bb2bab7ecee9ee diff --git a/src/main.ts b/src/main.ts index f6457a1..72e08df 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,7 +46,7 @@ import { LogAddOn } from "./features/CmdStatusInsideEditor.ts"; import { eventHub } from "./lib/src/hub/hub.ts"; import { EVENT_FILE_RENAMED, EVENT_LAYOUT_READY, EVENT_LEAF_ACTIVE_CHANGED, EVENT_PLUGIN_LOADED, EVENT_PLUGIN_UNLOADED, EVENT_SETTING_SAVED } from "./common/events.ts"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; - +import { yieldMicrotask } from "octagonal-wheels/promises"; setNoticeClass(Notice); @@ -354,10 +354,10 @@ export default class ObsidianLiveSyncPlugin extends Plugin return new PouchDB(name, optionPass); } beforeOnUnload(db: LiveSyncLocalDB): void { - this.kvDB.close(); + if (this.kvDB) this.kvDB.close(); } onClose(db: LiveSyncLocalDB): void { - this.kvDB.close(); + if (this.kvDB) this.kvDB.close(); } getNewReplicator(settingOverride: Partial = {}): LiveSyncAbstractReplicator { const settings = { ...this.settings, ...settingOverride }; @@ -367,8 +367,8 @@ export default class ObsidianLiveSyncPlugin extends Plugin return new LiveSyncCouchDBReplicator(this); } async onInitializeDatabase(db: LiveSyncLocalDB): Promise { + await delay(10); this.kvDB = await OpenKeyValueDatabase(db.dbname + "-livesync-kv"); - // this.trench = new Trench(this.simpleStore); this.replicator = this.getNewReplicator(); } async onResetDatabase(db: LiveSyncLocalDB): Promise { @@ -376,8 +376,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin this.kvDB.del(kvDBKey); // localStorage.removeItem(lsKey); await this.kvDB.destroy(); + await yieldMicrotask(); this.kvDB = await OpenKeyValueDatabase(db.dbname + "-livesync-kv"); - // this.trench = new Trench(this.simpleStore); + await yieldMicrotask(); this.replicator = this.getNewReplicator() } getReplicator() { @@ -710,7 +711,7 @@ ___However, to enable either of these changes, both remote and local databases n } async onLayoutReady() { - eventHub.emit(EVENT_LAYOUT_READY); + eventHub.emitEvent(EVENT_LAYOUT_READY); this.registerFileWatchEvents(); if (!this.localDatabase.isReady) { Logger(`Something went wrong! The local database is not ready`, LOG_LEVEL_NOTICE); @@ -1160,34 +1161,34 @@ Note: We can always able to read V1 format. It will be progressively converted. } wireUpEvents() { - eventHub.on(EVENT_SETTING_SAVED, (evt: CustomEvent) => { + eventHub.onEvent(EVENT_SETTING_SAVED, (evt: CustomEvent) => { const settings = evt.detail; this.localDatabase.settings = settings; setLang(settings.displayLanguage); this.settingTab.requestReload(); this.ignoreFiles = settings.ignoreFiles.split(",").map(e => e.trim()); }); - eventHub.on(EVENT_SETTING_SAVED, (evt: CustomEvent) => { + eventHub.onEvent(EVENT_SETTING_SAVED, (evt: CustomEvent) => { const settings = evt.detail; if (settings.settingSyncFile != "") { fireAndForget(() => this.saveSettingToMarkdown(settings.settingSyncFile)); } }) - eventHub.on(EVENT_SETTING_SAVED, (evt: CustomEvent) => { + eventHub.onEvent(EVENT_SETTING_SAVED, (evt: CustomEvent) => { fireAndForget(() => this.realizeSettingSyncMode()); }) } connectObsidianEvents() { // this.registerEvent(this.app.workspace.on("editor-change", )); this.registerEvent(this.app.vault.on("rename", (file, oldPath) => { - eventHub.emit(EVENT_FILE_RENAMED, { newPath: file.path, old: oldPath }); + eventHub.emitEvent(EVENT_FILE_RENAMED, { newPath: file.path, old: oldPath }); })); - this.registerEvent(this.app.workspace.on("active-leaf-change", () => eventHub.emit(EVENT_LEAF_ACTIVE_CHANGED))); + this.registerEvent(this.app.workspace.on("active-leaf-change", () => eventHub.emitEvent(EVENT_LEAF_ACTIVE_CHANGED))); } async onload() { this.wireUpEvents(); this.connectObsidianEvents(); - eventHub.emit(EVENT_PLUGIN_LOADED, this); + eventHub.emitEvent(EVENT_PLUGIN_LOADED, this); logStore.pipeTo(new QueueProcessor(logs => logs.forEach(e => this.addLog(e.message, e.level, e.key)), { suspended: false, batchSize: 20, concurrentLimit: 1, delay: 0 })).startPipeline(); Logger("loading plugin"); __onMissingTranslation(() => { }); @@ -1214,6 +1215,36 @@ Note: We can always able to read V1 format. It will be progressively converted. } }); }) + type STUB = { + toc: Set, + stub: { [key: string]: { [key: string]: Map> } } + }; + eventHub.onEvent("document-stub-created", async (e: CustomEvent) => { + const stub = e.detail.stub; + const toc = e.detail.toc; + + const stubDocX = + Object.entries(stub).map(([key, value]) => { + return [`## ${key}`, Object.entries(value). + map(([key2, value2]) => { + return [`### ${key2}`, + ([...(value2.entries())].map(([key3, value3]) => { + // return `#### ${key3}` + "\n" + JSON.stringify(value3); + const isObsolete = value3["is_obsolete"] ? " (obsolete)" : ""; + const desc = value3["desc"] ?? ""; + const key = value3["key"] ? "Setting key: " + value3["key"] + "\n" : ""; + return `#### ${key3}${isObsolete}\n${key}${desc}\n` + }))].flat() + }).flat()].flat() + }).flat(); + const stubDocMD = ` +| Icon | Description | +| :---: | ----------------------------------------------------------------- | +` + + [...toc.values()].map(e => `${e}`).join("\n") + "\n\n" + + stubDocX.join("\n"); + await this.vaultAccess.adapterWrite(this.app.vault.configDir + "/ls-debug/stub-doc.md", stubDocMD); + }) } this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this); this.addSettingTab(this.settingTab); @@ -1292,7 +1323,7 @@ Note: We can always able to read V1 format. It will be progressively converted. } onunload() { - eventHub.emit(EVENT_PLUGIN_UNLOADED); + eventHub.emitEvent(EVENT_PLUGIN_UNLOADED); cancelAllPeriodicTask(); cancelAllTasks(); stopAllRunningProcessors(); @@ -1305,7 +1336,7 @@ Note: We can always able to read V1 format. It will be progressively converted. } this.periodicSyncProcessor?.disable(); if (this.localDatabase != null) { - this.replicator.closeReplication(); + this.replicator?.closeReplication(); this.localDatabase.close(); } Logger($f`unloading plugin`); @@ -1496,7 +1527,7 @@ Note: We can always able to read V1 format. It will be progressively converted. } await this.saveData(settings); - eventHub.emit(EVENT_SETTING_SAVED, settings); + eventHub.emitEvent(EVENT_SETTING_SAVED, settings); } extractSettingFromWholeText(data: string): { preamble: string, body: string, postscript: string } { diff --git a/src/ui/ObsidianLiveSyncSettingTab.ts b/src/ui/ObsidianLiveSyncSettingTab.ts index 769ed04..c8bee79 100644 --- a/src/ui/ObsidianLiveSyncSettingTab.ts +++ b/src/ui/ObsidianLiveSyncSettingTab.ts @@ -1,4 +1,4 @@ -import { App, PluginSettingTab, Setting as SettingOrg, sanitizeHTMLToDom, MarkdownRenderer, stringifyYaml } from "../deps.ts"; +import { App, PluginSettingTab, MarkdownRenderer, stringifyYaml } from "../deps.ts"; import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings, @@ -18,26 +18,33 @@ import { REMOTE_COUCHDB, REMOTE_MINIO, PREFERRED_JOURNAL_SYNC, - statusDisplay, - type ConfigurationItem + FLAGMD_REDFLAG, + type ConfigLevel, + LEVEL_POWER_USER, + LEVEL_ADVANCED, + LEVEL_EDGE_CASE } from "../lib/src/common/types.ts"; -import { createBlob, delay, isDocContentSame, isObjectDifferent, readAsBlob, sizeToHumanReadable, unique } from "../lib/src/common/utils.ts"; +import { createBlob, delay, isDocContentSame, isObjectDifferent, readAsBlob, sizeToHumanReadable } from "../lib/src/common/utils.ts"; import { versionNumberString2Number } from "../lib/src/string_and_binary/convert.ts"; import { Logger } from "../lib/src/common/logger.ts"; import { checkSyncInfo, isCloudantURI } from "../lib/src/pouchdb/utils_couchdb.ts"; import { testCrypt } from "../lib/src/encryption/e2ee_v2.ts"; import ObsidianLiveSyncPlugin from "../main.ts"; import { askYesNo, performRebuildDB, requestToCouchDB, scheduleTask } from "../common/utils.ts"; -import { request, ButtonComponent, TFile, TextComponent, ToggleComponent, DropdownComponent, ValueComponent, TextAreaComponent } from "obsidian"; +import { request, TFile } from "obsidian"; import { shouldBeIgnored } from "../lib/src/string_and_binary/path.ts"; import MultipleRegExpControl from './components/MultipleRegExpControl.svelte'; import { LiveSyncCouchDBReplicator } from "../lib/src/replication/couchdb/LiveSyncReplicator.ts"; -import { type AllSettingItemKey, type AllStringItemKey, type AllNumericItemKey, type AllBooleanItemKey, type AllSettings, OnDialogSettingsDefault, getConfig, type OnDialogSettings, getConfName } from "./settingConstants.ts"; +import { type AllSettingItemKey, type AllStringItemKey, type AllNumericItemKey, type AllBooleanItemKey, type AllSettings, OnDialogSettingsDefault, type OnDialogSettings, getConfName } from "./settingConstants.ts"; import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "src/lib/src/common/rosetta.ts"; import { $t } from "src/lib/src/common/i18n.ts"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; +import { LiveSyncSetting as Setting } from "./components/LiveSyncSetting.ts"; +import { yieldNextAnimationFrame } from "octagonal-wheels/promises"; +import { confirmWithMessage } from "src/common/dialogs.ts"; +import { eventHub } from "../common/events.ts"; -type OnUpdateResult = { +export type OnUpdateResult = { visibility?: boolean, disabled?: boolean, classes?: string[], @@ -47,12 +54,13 @@ type OnUpdateResult = { type OnUpdateFunc = () => OnUpdateResult; type UpdateFunction = () => void; -type AutoWireOption = { +export type AutoWireOption = { placeHolder?: string, holdValue?: boolean, isPassword?: boolean, invert?: boolean, onUpdate?: OnUpdateFunc; + obsolete?: boolean; } function visibleOnly(cond: () => boolean): OnUpdateFunc { @@ -72,7 +80,44 @@ type OnSavedHandler = { handler: OnSavedHandlerFunc, } -function wrapMemo(func: (arg: T) => void) { +function getLevelStr(level: ConfigLevel) { + return level == LEVEL_POWER_USER ? " (Power User)" : + level == LEVEL_ADVANCED ? " (Advanced)" : + level == LEVEL_EDGE_CASE ? " (Edge Case)" : ""; +} + +export function findAttrFromParent(el: HTMLElement, attr: string): string { + let current: HTMLElement | null = el; + while (current) { + const value = current.getAttribute(attr); + if (value) { + return value; + } + current = current.parentElement; + } + return ""; +} + +// For creating a document +const toc = new Set(); +const stubs = {} as { [key: string]: { [key: string]: Map> } }; +export function createStub(name: string, key: string, value: string, panel: string, pane: string) { + DEV: { + if (!(pane in stubs)) { + stubs[pane] = {}; + } + if (!(panel in stubs[pane])) { + stubs[pane][panel] = new Map>(); + } + const old = stubs[pane][panel].get(name) ?? {}; + stubs[pane][panel].set(name, { ...old, [key]: value }); + scheduleTask("update-stub", 100, () => { + eventHub.emitEvent("document-stub-created", { toc: toc, stub: stubs }); + }); + } +} + +export function wrapMemo(func: (arg: T) => void) { let buf: T | undefined = undefined; return (arg: T) => { if (buf !== arg) { @@ -82,310 +127,6 @@ function wrapMemo(func: (arg: T) => void) { } } -class Setting extends SettingOrg { - autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent; - applyButtonComponent?: ButtonComponent; - selfKey?: AllSettingItemKey; - watchDirtyKeys = [] as AllSettingItemKey[]; - holdValue: boolean = false; - static env: ObsidianLiveSyncSettingTab; - - descBuf: string | DocumentFragment = ""; - nameBuf: string | DocumentFragment = ""; - placeHolderBuf: string = ""; - hasPassword: boolean = false; - - invalidateValue?: () => void; - setValue?: (value: any) => void; - constructor(containerEl: HTMLElement) { - super(containerEl); - Setting.env.settingComponents.push(this); - } - - setDesc(desc: string | DocumentFragment): this { - this.descBuf = desc; - super.setDesc(desc); - return this; - } - setName(name: string | DocumentFragment): this { - this.nameBuf = name; - super.setName(name); - return this; - } - setAuto(key: AllSettingItemKey, opt?: AutoWireOption) { - this.autoWireSetting(key, opt); - return this; - } - autoWireSetting(key: AllSettingItemKey, opt?: AutoWireOption) { - const conf = getConfig(key); - if (!conf) { - // throw new Error(`No such setting item :${key}`) - return; - } - const name = `${conf.name}${statusDisplay(conf.status)}`; - this.setName(name); - if (conf.desc) { - this.setDesc(conf.desc); - } - this.holdValue = opt?.holdValue || this.holdValue; - this.selfKey = key; - if (opt?.onUpdate) this.addOnUpdate(opt.onUpdate); - const stat = this._getComputedStatus(); - if (stat.visibility === false) { - this.settingEl.toggleClass("sls-setting-hidden", !stat.visibility); - } - return conf; - } - autoWireComponent(component: ValueComponent, conf?: ConfigurationItem, opt?: AutoWireOption) { - this.placeHolderBuf = conf?.placeHolder || opt?.placeHolder || ""; - if (this.placeHolderBuf && component instanceof TextComponent) { - component.setPlaceholder(this.placeHolderBuf) - } - if (opt?.onUpdate) this.addOnUpdate(opt.onUpdate); - } - async commitValue(value: AllSettings[T]) { - const key = this.selfKey as T; - if (key !== undefined) { - if (value != Setting.env.editingSettings[key]) { - Setting.env.editingSettings[key] = value; - if (!this.holdValue) { - await Setting.env.saveSettings([key]); - } - } - } - Setting.env.requestUpdate() - } - autoWireText(key: AllStringItemKey, opt?: AutoWireOption) { - const conf = this.autoWireSetting(key, opt); - this.addText(text => { - this.autoWiredComponent = text; - const setValue = wrapMemo((value: string) => text.setValue(value)); - this.invalidateValue = () => setValue(`${Setting.env.editingSettings[key]}`); - this.invalidateValue(); - text.onChange(async value => { - await this.commitValue(value); - }) - if (opt?.isPassword) { - text.inputEl.setAttribute("type", "password") - this.hasPassword = true; - } - this.autoWireComponent(this.autoWiredComponent, conf, opt); - }) - return this; - } - autoWireTextArea(key: AllStringItemKey, opt?: AutoWireOption) { - const conf = this.autoWireSetting(key, opt); - this.addTextArea(text => { - this.autoWiredComponent = text; - const setValue = wrapMemo((value: string) => text.setValue(value)); - this.invalidateValue = () => setValue(`${Setting.env.editingSettings[key]}`); - this.invalidateValue(); - text.onChange(async value => { - await this.commitValue(value); - }) - if (opt?.isPassword) { - text.inputEl.setAttribute("type", "password") - this.hasPassword = true; - } - this.autoWireComponent(this.autoWiredComponent, conf, opt); - }) - return this; - } - autoWireNumeric(key: AllNumericItemKey, opt: AutoWireOption & { clampMin?: number, clampMax?: number, acceptZero?: boolean }) { - const conf = this.autoWireSetting(key, opt); - this.addText(text => { - this.autoWiredComponent = text; - if (opt.clampMin) { - text.inputEl.setAttribute("min", `${opt.clampMin}`); - } - if (opt.clampMax) { - text.inputEl.setAttribute("max", `${opt.clampMax}`); - } - let lastError = false; - const setValue = wrapMemo((value: string) => text.setValue(value)); - this.invalidateValue = () => { - if (!lastError) setValue(`${Setting.env.editingSettings[key]}`); - } - this.invalidateValue(); - text.onChange(async TextValue => { - const parsedValue = Number(TextValue); - const value = parsedValue; - let hasError = false; - if (isNaN(value)) hasError = true; - if (opt.clampMax && opt.clampMax < value) hasError = true; - if (opt.clampMin && opt.clampMin > value) { - if (opt.acceptZero && value == 0) { - // This is ok. - } else { - hasError = true; - } - } - if (!hasError) { - lastError = false; - this.setTooltip(``); - text.inputEl.toggleClass("sls-item-invalid-value", false); - await this.commitValue(value); - } else { - this.setTooltip(`The value should ${opt.clampMin || "~"} < value < ${opt.clampMax || "~"}`); - text.inputEl.toggleClass("sls-item-invalid-value", true); - lastError = true; - return false; - } - }) - text.inputEl.setAttr("type", "number"); - this.autoWireComponent(this.autoWiredComponent, conf, opt); - }) - return this; - } - autoWireToggle(key: AllBooleanItemKey, opt?: AutoWireOption) { - const conf = this.autoWireSetting(key, opt); - this.addToggle(toggle => { - this.autoWiredComponent = toggle; - const setValue = wrapMemo((value: boolean) => toggle.setValue(opt?.invert ? !value : value)); - this.invalidateValue = () => setValue(Setting.env.editingSettings[key] ?? false); - this.invalidateValue(); - - toggle.onChange(async value => { - await this.commitValue(opt?.invert ? !value : value); - }) - - this.autoWireComponent(this.autoWiredComponent, conf, opt); - }) - return this; - } - autoWireDropDown(key: AllStringItemKey, opt: AutoWireOption & { options: Record }) { - const conf = this.autoWireSetting(key, opt); - this.addDropdown(dropdown => { - this.autoWiredComponent = dropdown; - const setValue = wrapMemo((value: string) => { - dropdown.setValue(value) - }); - - dropdown - .addOptions(opt.options) - - this.invalidateValue = () => setValue(Setting.env.editingSettings[key] || ""); - this.invalidateValue(); - dropdown.onChange(async value => { - await this.commitValue(value); - }) - this.autoWireComponent(this.autoWiredComponent, conf, opt); - }) - return this; - } - addApplyButton(keys: AllSettingItemKey[], text?: string) { - this.addButton((button) => { - this.applyButtonComponent = button; - this.watchDirtyKeys = unique([...keys, ...this.watchDirtyKeys]); - button.setButtonText(text ?? "Apply") - button.onClick(async () => { - await Setting.env.saveSettings(keys); - Setting.env.reloadAllSettings(); - }) - Setting.env.requestUpdate() - }) - return this; - } - addOnUpdate(func: () => OnUpdateResult) { - this.updateHandlers.add(func); - // this._applyOnUpdateHandlers(); - return this; - } - updateHandlers = new Set<() => OnUpdateResult>(); - - prevStatus: OnUpdateResult = {}; - - _getComputedStatus() { - let newConf = {} as OnUpdateResult; - for (const handler of this.updateHandlers) { - newConf = { - ...newConf, - ...handler(), - } - } - return newConf; - } - _applyOnUpdateHandlers() { - if (this.updateHandlers.size > 0) { - const newConf = this._getComputedStatus(); - const keys = Object.keys(newConf) as [keyof OnUpdateResult]; - for (const k of keys) { - - if (k in this.prevStatus && this.prevStatus[k] == newConf[k]) { - continue; - } - // const newValue = newConf[k]; - switch (k) { - case "visibility": - this.settingEl.toggleClass("sls-setting-hidden", !(newConf[k] || false)) - this.prevStatus[k] = newConf[k]; - break - case "classes": - break - case "disabled": - this.setDisabled((newConf[k] || false)) - this.settingEl.toggleClass("sls-setting-disabled", (newConf[k] || false)) - this.prevStatus[k] = newConf[k]; - break - case "isCta": - { - const component = this.autoWiredComponent; - if (component instanceof ButtonComponent) { - if (newConf[k]) { - component.setCta(); - } else { - component.removeCta(); - } - } - this.prevStatus[k] = newConf[k]; - } - break - case "isWarning": - { - const component = this.autoWiredComponent; - if (component instanceof ButtonComponent) { - if (newConf[k]) { - component.setWarning(); - } else { - //TODO:IMPLEMENT - // component.removeCta(); - } - } - this.prevStatus[k] = newConf[k]; - } - break - } - } - } - } - _onUpdate() { - if (this.applyButtonComponent) { - const isDirty = Setting.env.isSomeDirty(this.watchDirtyKeys); - this.applyButtonComponent.setDisabled(!isDirty); - if (isDirty) { - this.applyButtonComponent.setCta(); - } else { - this.applyButtonComponent.removeCta(); - } - } - if (this.selfKey && !Setting.env.isDirty(this.selfKey) && this.invalidateValue) { - this.invalidateValue(); - } - if (this.holdValue && this.selfKey) { - const isDirty = Setting.env.isDirty(this.selfKey); - const alt = isDirty ? `Original: ${Setting.env.initialSettings![this.selfKey]}` : "" - this.controlEl.toggleClass("sls-item-dirty", isDirty); - if (!this.hasPassword) { - this.nameEl.toggleClass("sls-item-dirty-help", isDirty); - this.setTooltip(alt, { delay: 10, placement: "right" }); - } - } - this._applyOnUpdateHandlers(); - } - -} - - export class ObsidianLiveSyncSettingTab extends PluginSettingTab { plugin: ObsidianLiveSyncPlugin; selectedScreen = ""; @@ -577,6 +318,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { controlledElementFunc = [] as UpdateFunction[]; onSavedHandlers = [] as OnSavedHandler[]; + inWizard: boolean = false; + constructor(app: App, plugin: ObsidianLiveSyncPlugin) { super(app, plugin); this.plugin = plugin; @@ -632,6 +375,15 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { return element; } + addEl(el: HTMLElement, + tag: T, + o?: string | DomElementInfo | undefined, + callback?: ((el: HTMLElementTagNameMap[T]) => void), + func?: OnUpdateFunc) { + const elm = this.createEl(el, tag, o, callback, func); + return Promise.resolve(elm); + } + addOnSaved(key: T, func: (value: AllSettings[T]) => (Promise | void)) { this.onSavedHandlers.push({ key, handler: func }); } @@ -694,12 +446,23 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { this.isShown = true; containerEl.empty(); - this.createEl(containerEl, "h2", { text: "Settings for Self-hosted LiveSync." }); + containerEl.addClass("sls-setting"); containerEl.removeClass("isWizard"); + const setStyle = (el: HTMLElement, styleHead: string, condition: () => boolean) => { + if (condition()) { + el.addClass(`${styleHead}-enabled`); + el.removeClass(`${styleHead}-disabled`); + } else { + el.addClass(`${styleHead}-disabled`); + el.removeClass(`${styleHead}-enabled`); + } + } + setStyle(containerEl, "menu-setting-poweruser", () => this.isConfiguredAs("usePowerUserMode", true)); + setStyle(containerEl, "menu-setting-advanced", () => this.isConfiguredAs("useAdvancedMode", true)); + setStyle(containerEl, "menu-setting-edgecase", () => this.isConfiguredAs("useEdgeCaseMode", true)); - const w = containerEl.createDiv(""); const screenElements: { [key: string]: HTMLElement[] } = {}; const addScreenElement = (key: string, element: HTMLElement) => { if (!(key in screenElements)) { @@ -707,18 +470,181 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { } screenElements[key].push(element); }; + const menuWrapper = this.createEl(containerEl, "div", { cls: "sls-setting-menu-wrapper" }); + + const w = menuWrapper.createDiv(""); w.addClass("sls-setting-menu"); - w.innerHTML = ` - - - - - - - - - `; const menuTabs = w.querySelectorAll(".sls-setting-label"); + const selectPane = (event: Event) => { + const target = event.target as HTMLElement; + if (target.tagName == "INPUT") { + const value = target.getAttribute("value"); + if (value && this.selectedScreen != value) { + changeDisplay(value); + // target.parentElement?.parentElement?.querySelector(".sls-setting-label.selected")?.removeClass("selected"); + // target.parentElement?.addClass("selected"); + } + } + } + const isNeedRebuildLocal = () => { + return this.isSomeDirty( + [ + "useIndexedDBAdapter", + "doNotUseFixedRevisionForChunks", + "handleFilenameCaseSensitive", + "passphrase", + "useDynamicIterationCount", + "usePathObfuscation", + "encrypt", + // "remoteType", + ]) + } + const isNeedRebuildRemote = () => { + return this.isSomeDirty( + [ + "doNotUseFixedRevisionForChunks", + "handleFilenameCaseSensitive", + "passphrase", + "useDynamicIterationCount", + "usePathObfuscation", + "encrypt" + ]) + }; + const confirmRebuild = async () => { + if (!await isPassphraseValid()) { + Logger(`Passphrase is not valid, please fix it.`, LOG_LEVEL_NOTICE); + return; + } + const OPTION_FETCH = `Fetch from Remote`; + const OPTION_REBUILD_BOTH = `Rebuild Both from This Device`; + const OPTION_ONLY_SETTING = `(Danger) Save Only Settings`; + const OPTION_CANCEL = `Cancel`; + const title = `Rebuild Required`; + const note = `Rebuilding Databases are required to apply the changes.. Please select the method to apply the changes. + +
+Legends + +| Symbol | Meaning | +|: ------ :| ------- | +| ⇔ | Synchronised or well balanced | +| ⇄ | Synchronise to balance | +| ⇐,β‡’ | Transfer to overwrite | +| β‡ ,β‡’ | Transfer to overwrite from other side | + +
+ +## ${OPTION_REBUILD_BOTH} +At a glance: πŸ“„ β‡’ΒΉ πŸ’» β‡’Β² πŸ›°οΈ ⇒ⁿ πŸ’» ⇄ⁿ⁺¹ πŸ“„ +Reconstruct both the local and remote databases using existing files from this device. +This causes a lockout other devices, and they need to perform fetching. +## ${OPTION_FETCH} +At a glance: πŸ“„ ⇄² πŸ’» ⇐¹ πŸ›°οΈ ⇔ πŸ’» ⇔ πŸ“„ +Initialise the local database and reconstruct it using data fetched from the remote database. +This case includes the case which you have rebuilt the remote database. +## ${OPTION_ONLY_SETTING} +Store only the settings. **Caution: This may lead to data corruption**; database reconstruction is generally necessary.`; + const buttons = [ + OPTION_FETCH, + OPTION_REBUILD_BOTH, + // OPTION_REBUILD_REMOTE, + OPTION_ONLY_SETTING, + OPTION_CANCEL + ]; + const result = await confirmWithMessage(this.plugin, + title, note, + buttons, + OPTION_CANCEL, 0 + ) + if (result == OPTION_CANCEL) return; + if (result == OPTION_FETCH) { + if (!await checkWorkingPassphrase()) { + if (await askYesNo(this.app, "Are you sure to proceed?") != "yes") return; + } + } + if (!this.editingSettings.encrypt) { + this.editingSettings.passphrase = ""; + } + await this.saveAllDirtySettings(); + await this.applyAllSettings(); + if (result == OPTION_FETCH) { + await this.plugin.vaultAccess.vaultCreate(FLAGMD_REDFLAG3_HR, ""); + this.plugin.scheduleAppReload(); + this.closeSetting(); + // await rebuildDB("localOnly"); + } else if (result == OPTION_REBUILD_BOTH) { + await this.plugin.vaultAccess.vaultCreate(FLAGMD_REDFLAG2_HR, ""); + this.plugin.scheduleAppReload(); + this.closeSetting(); + } else if (result == OPTION_ONLY_SETTING) { + await this.plugin.saveSettings(); + } + + } + this.createEl(menuWrapper, "div", { cls: "sls-setting-menu-buttons" }, (el) => { + el.addClass("wizardHidden"); + el.createEl("label", { text: "Changes need to be applied!" }); + this.addEl(el, "button", { text: "Apply", cls: "mod-warning" }, buttonEl => { + buttonEl.addEventListener("click", async () => await confirmRebuild()) + }) + }, visibleOnly(() => isNeedRebuildLocal() || isNeedRebuildRemote())); + const setLevelClass = (el: HTMLElement, level?: ConfigLevel) => { + switch (level) { + case LEVEL_POWER_USER: el.addClass("sls-setting-poweruser"); + break; + case LEVEL_ADVANCED: el.addClass("sls-setting-advanced"); + break; + case LEVEL_EDGE_CASE: el.addClass("sls-setting-edgecase"); + break; + default: + // NO OP. + } + } + let paneNo = 0; + const addPane = (parentEl: HTMLElement, title: string, icon: string, order: number, wizardHidden: boolean, level?: ConfigLevel) => { + const el = this.createEl(parentEl, "div", { text: "" }); + DEV: { + const mdTitle = `${paneNo++}. ${title}${getLevelStr(level ?? "")}`; + el.setAttribute("data-pane", mdTitle); + toc.add(`| ${icon} | [${mdTitle}](#${mdTitle.toLowerCase().replace(/ /g, "-").replace(/[^\w\s-]/g, "")}) | `); + } + setLevelClass(el, level) + el.createEl("h3", { text: title, cls: "sls-setting-pane-title" }); + w.createEl("label", { cls: `sls-setting-label c-${order} ${wizardHidden ? "wizardHidden" : ""}` }, el => { + setLevelClass(el, level) + const inputEl = el.createEl("input", { type: "radio", name: "disp", value: `${order}`, cls: "sls-setting-tab" } as DomElementInfo); + el.createEl("div", { cls: "sls-setting-menu-btn", text: icon, title: title }); + inputEl.addEventListener("change", selectPane); + inputEl.addEventListener("click", selectPane); + }) + addScreenElement(`${order}`, el); + const p = Promise.resolve(el) + p.finally(() => { + // Recap at the end. + }); + return p; + } + const panelNoMap = {} as { [key: string]: number }; + const addPanel = (parentEl: HTMLElement, title: string, callback?: ((el: HTMLDivElement) => void), func?: OnUpdateFunc, level?: ConfigLevel) => { + const el = this.createEl(parentEl, "div", { text: "" }, callback, func); + DEV: { + const paneNo = findAttrFromParent(parentEl, "data-pane"); + if (!(paneNo in panelNoMap)) { + panelNoMap[paneNo] = 0; + } + panelNoMap[paneNo] += 1; + const panelNo = panelNoMap[paneNo]; + el.setAttribute("data-panel", `${panelNo}. ${title}${getLevelStr(level ?? "")}`); + } + setLevelClass(el, level) + this.createEl(el, "h4", { text: title, cls: "sls-setting-panel-title" }); + const p = Promise.resolve(el); + p.finally(() => { + // Recap at the end. + }) + return p; + } + const changeDisplay = (screen: string) => { for (const k in screenElements) { if (k == screen) { @@ -728,13 +654,15 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { } } w.querySelectorAll(`.sls-setting-label`).forEach((element) => { - element.removeClass("selected"); - (element.querySelector("input[type=radio]"))!.checked = false; - }); - w.querySelectorAll(`.sls-setting-label.c-${screen}`).forEach((element) => { - element.addClass("selected"); - (element.querySelector("input[type=radio]"))!.checked = true; - }); + if (element.hasClass(`c-${screen}`)) { + element.addClass("selected"); + (element.querySelector("input[type=radio]"))!.checked = true; + } else { + element.removeClass("selected"); + (element.querySelector("input[type=radio]"))!.checked = false; + } + } + ); this.selectedScreen = screen; }; menuTabs.forEach((element) => { @@ -747,10 +675,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { }); }); - const containerInformationEl = containerEl.createDiv(); - const h3El = this.createEl(containerInformationEl, "h3", { text: "Updates" }); - const informationDivEl = this.createEl(containerInformationEl, "div", { text: "" }); - //@ts-ignore const manifestVersion: string = MANIFEST_VERSION || "-"; //@ts-ignore @@ -758,23 +682,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000); - const tmpDiv = createSpan(); - tmpDiv.addClass("sls-header-button"); - tmpDiv.innerHTML = ``; - if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) { - const informationButtonDiv = h3El.appendChild(tmpDiv); - informationButtonDiv.querySelector("button")?.addEventListener("click", async () => { - this.editingSettings.lastReadUpdates = lastVersion; - await this.saveAllDirtySettings(); - informationButtonDiv.remove(); - }); - - } - - MarkdownRenderer.render(this.plugin.app, updateInformation, informationDivEl, "/", this.plugin); - - - addScreenElement("100", containerInformationEl); const isAnySyncEnabled = (): boolean => { if (this.isConfiguredAs("isConfigured", false)) return false; if (this.isConfiguredAs("liveSync", true)) return true; @@ -789,145 +696,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { if (this.plugin?.replicator?.syncStatus == "PAUSED") return true; return false; }; - // const visibleOnlySyncDisabled = visibleOnly(() => !isAnySyncEnabled()) - // const visibleOnlySyncDisabled = visibleOnly(() => !isAnySyncEnabled()) const enableOnlySyncDisabled = enableOnly(() => !isAnySyncEnabled()) - - let inWizard = false; - if (containerEl.hasClass("inWizard")) { - inWizard = true; - } - - const setupWizardEl = containerEl.createDiv(); - this.createEl(setupWizardEl, "h3", { text: "Setup wizard" }); - new Setting(setupWizardEl) - .setName("Use the copied setup URI") - .setDesc("To setup Self-hosted LiveSync, this method is the most preferred one.") - .addButton((text) => { - text.setButtonText("Use").onClick(async () => { - this.closeSetting(); - await this.plugin.addOnSetup.command_openSetupURI(); - }) - }) - if (this.editingSettings.isConfigured) { - new Setting(setupWizardEl) - .setName("Copy current settings as a new setup URI") - .addButton((text) => { - text.setButtonText("Copy").onClick(async () => { - await this.plugin.addOnSetup.command_copySetupURI(); - }) - }) - } - new Setting(setupWizardEl) - .setName("Minimal setup") - .addButton((text) => { - text.setButtonText("Start").onClick(async () => { - this.editingSettings.liveSync = false; - this.editingSettings.periodicReplication = false; - this.editingSettings.syncOnSave = false; - this.editingSettings.syncOnEditorSave = false; - this.editingSettings.syncOnStart = false; - this.editingSettings.syncOnFileOpen = false; - this.editingSettings.syncAfterMerge = false; - this.plugin.replicator.closeReplication(); - await this.saveAllDirtySettings(); - containerEl.addClass("isWizard"); - inWizard = true; - changeDisplay("0") - }) - }) - new Setting(setupWizardEl) - .setName("Enable LiveSync on this device as the setup was completed manually") - .addButton((text) => { - text.setButtonText("Enable").onClick(async () => { - this.editingSettings.isConfigured = true; - await this.saveAllDirtySettings(); - this.plugin.askReload(); - }) - }) - .addOnUpdate(visibleOnly(() => !this.isConfiguredAs("isConfigured", true))) - - new Setting(setupWizardEl) - .setName("Discard existing settings and databases") - .addButton((text) => { - text.setButtonText("Discard").onClick(async () => { - if (await askYesNo(this.plugin.app, "Do you really want to discard existing settings and databases?") == "yes") { - this.editingSettings = { ...this.editingSettings, ...DEFAULT_SETTINGS }; - await this.plugin.saveSettingData(); - await this.plugin.resetLocalDatabase(); - // await this.plugin.initializeDatabase(); - this.plugin.askReload(); - } - }).setWarning() - }).addOnUpdate(visibleOnly(() => this.isConfiguredAs("isConfigured", true))) - // } - this.createEl(setupWizardEl, "h3", { text: "Online Tips" }); - const repo = "vrtmrz/obsidian-livesync"; - const topPath = "/docs/troubleshooting.md"; - const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`; - this.createEl(setupWizardEl, "div", "", el => el.innerHTML = `Open in browser`); - const troubleShootEl = this.createEl(setupWizardEl, "div", { text: "", cls: "sls-troubleshoot-preview" }); - const loadMarkdownPage = async (pathAll: string, basePathParam: string = "") => { - troubleShootEl.style.minHeight = troubleShootEl.clientHeight + "px"; - troubleShootEl.empty(); - const fullPath = pathAll.startsWith("/") ? pathAll : `${basePathParam}/${pathAll}`; - - const directoryArr = fullPath.split("/"); - const filename = directoryArr.pop(); - const directly = directoryArr.join("/"); - const basePath = directly; - - let remoteTroubleShootMDSrc = ""; - try { - remoteTroubleShootMDSrc = await request(`${rawRepoURI}${basePath}/${filename}`); - } catch (ex: any) { - remoteTroubleShootMDSrc = "An error occurred!!\n" + ex.toString(); - } - const remoteTroubleShootMD = remoteTroubleShootMDSrc.replace(/\((.*?(.png)|(.jpg))\)/g, `(${rawRepoURI}${basePath}/$1)`) - // Render markdown - await MarkdownRenderer.render(this.plugin.app, ` [Tips and Troubleshooting](${topPath}) [PageTop](${filename})\n\n${remoteTroubleShootMD}`, troubleShootEl, `${rawRepoURI}`, this.plugin); - // Menu - troubleShootEl.querySelector(".sls-troubleshoot-anchor")?.parentElement?.setCssStyles({ - position: "sticky", - top: "-1em", - backgroundColor: "var(--modal-background)" - }); - // Trap internal links. - troubleShootEl.querySelectorAll("a.internal-link").forEach((anchorEl) => { - anchorEl.addEventListener("click", async (evt) => { - const uri = anchorEl.getAttr("data-href"); - if (!uri) return; - if (uri.startsWith("#")) { - evt.preventDefault(); - const elements = Array.from(troubleShootEl.querySelectorAll("[data-heading]")) - const p = elements.find(e => e.getAttr("data-heading")?.toLowerCase().split(" ").join("-") == uri.substring(1).toLowerCase()); - if (p) { - p.setCssStyles({ scrollMargin: "3em" }); - p.scrollIntoView({ behavior: "instant", block: "start" }); - } - } else { - evt.preventDefault(); - await loadMarkdownPage(uri, basePath); - troubleShootEl.setCssStyles({ scrollMargin: "1em" }); - troubleShootEl.scrollIntoView({ behavior: "instant", block: "start" }); - } - }) - }) - troubleShootEl.style.minHeight = ""; - } - loadMarkdownPage(topPath); - addScreenElement("110", setupWizardEl); - - const containerRemoteDatabaseEl = containerEl.createDiv(); - this.createEl(containerRemoteDatabaseEl, "h3", { text: "Remote configuration" }); - - new Setting(containerRemoteDatabaseEl) - .autoWireDropDown("remoteType", { - holdValue: true, options: { - [REMOTE_COUCHDB]: "CouchDB", [REMOTE_MINIO]: "Minio,S3,R2", - }, onUpdate: enableOnlySyncDisabled - }) - const onlyOnCouchDB = () => ({ visibility: this.isConfiguredAs('remoteType', REMOTE_COUCHDB) }) as OnUpdateResult; @@ -935,390 +704,44 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { visibility: this.isConfiguredAs('remoteType', REMOTE_MINIO) }) as OnUpdateResult; - this.createEl(containerRemoteDatabaseEl, "div", undefined, containerRemoteDatabaseEl => { - - const syncWarnMinio = this.createEl(containerRemoteDatabaseEl, "div", { - text: "" - }); - const ObjectStorageMessage = `Kindly notice: this is a pretty experimental feature, hence we have some limitations. -- Append only architecture. It will not shrink used storage if we do not perform a rebuild. -- A bit fragile. -- During the first synchronization, the entire history to date will be transferred. For this reason, it is preferable to do this while connected to a Wi-Fi network. -- From the second, we always transfer only differences. - -However, your report is needed to stabilise this. I appreciate you for your great dedication. -`; - - MarkdownRenderer.render(this.plugin.app, ObjectStorageMessage, syncWarnMinio, "/", this.plugin); - syncWarnMinio.addClass("op-warn-info"); - - new Setting(containerRemoteDatabaseEl).autoWireText("endpoint", { holdValue: true }) - new Setting(containerRemoteDatabaseEl).autoWireText("accessKey", { holdValue: true }); - - new Setting(containerRemoteDatabaseEl).autoWireText("secretKey", { holdValue: true, isPassword: true }); - - new Setting(containerRemoteDatabaseEl).autoWireText("region", { holdValue: true }); - - new Setting(containerRemoteDatabaseEl).autoWireText("bucket", { holdValue: true }); - - new Setting(containerRemoteDatabaseEl).autoWireToggle("useCustomRequestHandler", { holdValue: true }); - new Setting(containerRemoteDatabaseEl) - .setName("Test Connection") - .addButton((button) => - button - .setButtonText("Test") - .setDisabled(false) - .onClick(async () => { - await this.testConnection(this.editingSettings); - }) - ); - new Setting(containerRemoteDatabaseEl) - .setName("Apply Settings") - .setClass("wizardHidden") - .addApplyButton(["remoteType", "endpoint", "region", "accessKey", "secretKey", "bucket", "useCustomRequestHandler"]) - .addOnUpdate(onlyOnMinIO) - - }, onlyOnMinIO); - - - this.createEl(containerRemoteDatabaseEl, "div", undefined, containerRemoteDatabaseEl => { - if (this.plugin.isMobile) { - this.createEl(containerRemoteDatabaseEl, "div", { - text: `Configured as using non-HTTPS. We cannot connect to the remote. Please set up the credentials and use HTTPS for the remote URI.`, - }, undefined, visibleOnly(() => !this.editingSettings.couchDB_URI.startsWith("https://"))) - .addClass("op-warn"); - } else { - this.createEl(containerRemoteDatabaseEl, "div", { - text: `Configured as using non-HTTPS. We might fail on mobile devices.` - }, undefined, visibleOnly(() => !this.editingSettings.couchDB_URI.startsWith("https://"))) - .addClass("op-warn-info"); - } - - this.createEl(containerRemoteDatabaseEl, "div", { text: `These settings are kept locked while any synchronization options are enabled. Disable these options in the "Sync Settings" tab to unlock.` }, - undefined, visibleOnly(() => isAnySyncEnabled()) - ).addClass("sls-setting-hidden"); - - new Setting(containerRemoteDatabaseEl).autoWireText("couchDB_URI", { holdValue: true, onUpdate: enableOnlySyncDisabled }); - new Setting(containerRemoteDatabaseEl).autoWireText("couchDB_USER", { holdValue: true, onUpdate: enableOnlySyncDisabled }); - new Setting(containerRemoteDatabaseEl).autoWireText("couchDB_PASSWORD", { holdValue: true, isPassword: true, onUpdate: enableOnlySyncDisabled }); - new Setting(containerRemoteDatabaseEl).autoWireText("couchDB_DBNAME", { holdValue: true, onUpdate: enableOnlySyncDisabled }); - - - new Setting(containerRemoteDatabaseEl) - .setName("Test Database Connection") - .setClass("wizardHidden") - .setDesc("Open database connection. If the remote database is not found and you have the privilege to create a database, the database will be created.") - .addButton((button) => - button - .setButtonText("Test") - .setDisabled(false) - .onClick(async () => { - await this.testConnection(); - }) - ); - - new Setting(containerRemoteDatabaseEl) - .setName("Check and fix database configuration") - .setDesc("Check the database configuration, and fix if there are any problems.") - .addButton((button) => - button - .setButtonText("Check") - .setDisabled(false) - .onClick(async () => { - const checkConfig = async () => { - Logger(`Checking database configuration`, LOG_LEVEL_INFO); - - const emptyDiv = createDiv(); - emptyDiv.innerHTML = ""; - checkResultDiv.replaceChildren(...[emptyDiv]); - const addResult = (msg: string, classes?: string[]) => { - const tmpDiv = createDiv(); - tmpDiv.addClass("ob-btn-config-fix"); - if (classes) { - tmpDiv.addClasses(classes); - } - tmpDiv.innerHTML = `${msg}`; - checkResultDiv.appendChild(tmpDiv); - }; - try { - - if (isCloudantURI(this.editingSettings.couchDB_URI)) { - Logger("This feature cannot be used with IBM Cloudant.", LOG_LEVEL_NOTICE); - return; - } - const r = await requestToCouchDB(this.editingSettings.couchDB_URI, this.editingSettings.couchDB_USER, this.editingSettings.couchDB_PASSWORD, window.origin); - const responseConfig = r.json; - - const addConfigFixButton = (title: string, key: string, value: string) => { - const tmpDiv = createDiv(); - tmpDiv.addClass("ob-btn-config-fix"); - tmpDiv.innerHTML = ``; - const x = checkResultDiv.appendChild(tmpDiv); - x.querySelector("button")?.addEventListener("click", async () => { - Logger(`CouchDB Configuration: ${title} -> Set ${key} to ${value}`) - const res = await requestToCouchDB(this.editingSettings.couchDB_URI, this.editingSettings.couchDB_USER, this.editingSettings.couchDB_PASSWORD, undefined, key, value); - if (res.status == 200) { - Logger(`CouchDB Configuration: ${title} successfully updated`, LOG_LEVEL_NOTICE); - checkResultDiv.removeChild(x); - checkConfig(); - } else { - Logger(`CouchDB Configuration: ${title} failed`, LOG_LEVEL_NOTICE); - Logger(res.text, LOG_LEVEL_VERBOSE); - } - }); - }; - addResult("---Notice---", ["ob-btn-config-head"]); - addResult( - "If the server configuration is not persistent (e.g., running on docker), the values set from here will also be volatile. Once you are able to connect, please reflect the settings in the server's local.ini.", - ["ob-btn-config-info"] - ); - - addResult("--Config check--", ["ob-btn-config-head"]); - - // Admin check - // for database creation and deletion - if (!(this.editingSettings.couchDB_USER in responseConfig.admins)) { - addResult(`⚠ You do not have administrative privileges.`); - } else { - addResult("βœ” You have administrative privileges."); - } - // HTTP user-authorization check - if (responseConfig?.chttpd?.require_valid_user != "true") { - addResult("❗ chttpd.require_valid_user is wrong."); - addConfigFixButton("Set chttpd.require_valid_user = true", "chttpd/require_valid_user", "true"); - } else { - addResult("βœ” chttpd.require_valid_user is ok."); - } - if (responseConfig?.chttpd_auth?.require_valid_user != "true") { - addResult("❗ chttpd_auth.require_valid_user is wrong."); - addConfigFixButton("Set chttpd_auth.require_valid_user = true", "chttpd_auth/require_valid_user", "true"); - } else { - addResult("βœ” chttpd_auth.require_valid_user is ok."); - } - // HTTPD check - // Check Authentication header - if (!responseConfig?.httpd["WWW-Authenticate"]) { - addResult("❗ httpd.WWW-Authenticate is missing"); - addConfigFixButton("Set httpd.WWW-Authenticate", "httpd/WWW-Authenticate", 'Basic realm="couchdb"'); - } else { - addResult("βœ” httpd.WWW-Authenticate is ok."); - } - if (responseConfig?.httpd?.enable_cors != "true") { - addResult("❗ httpd.enable_cors is wrong"); - addConfigFixButton("Set httpd.enable_cors", "httpd/enable_cors", "true"); - } else { - addResult("βœ” httpd.enable_cors is ok."); - } - // If the server is not cloudant, configure request size - if (!isCloudantURI(this.editingSettings.couchDB_URI)) { - // REQUEST SIZE - if (Number(responseConfig?.chttpd?.max_http_request_size ?? 0) < 4294967296) { - addResult("❗ chttpd.max_http_request_size is low)"); - addConfigFixButton("Set chttpd.max_http_request_size", "chttpd/max_http_request_size", "4294967296"); - } else { - addResult("βœ” chttpd.max_http_request_size is ok."); - } - if (Number(responseConfig?.couchdb?.max_document_size ?? 0) < 50000000) { - addResult("❗ couchdb.max_document_size is low)"); - addConfigFixButton("Set couchdb.max_document_size", "couchdb/max_document_size", "50000000"); - } else { - addResult("βœ” couchdb.max_document_size is ok."); - } - } - // CORS check - // checking connectivity for mobile - if (responseConfig?.cors?.credentials != "true") { - addResult("❗ cors.credentials is wrong"); - addConfigFixButton("Set cors.credentials", "cors/credentials", "true"); - } else { - addResult("βœ” cors.credentials is ok."); - } - const ConfiguredOrigins = ((responseConfig?.cors?.origins ?? "") + "").split(","); - if ( - responseConfig?.cors?.origins == "*" || - (ConfiguredOrigins.indexOf("app://obsidian.md") !== -1 && ConfiguredOrigins.indexOf("capacitor://localhost") !== -1 && ConfiguredOrigins.indexOf("http://localhost") !== -1) - ) { - addResult("βœ” cors.origins is ok."); - } else { - addResult("❗ cors.origins is wrong"); - addConfigFixButton("Set cors.origins", "cors/origins", "app://obsidian.md,capacitor://localhost,http://localhost"); - } - addResult("--Connection check--", ["ob-btn-config-head"]); - addResult(`Current origin:${window.location.origin}`); - - // Request header check - const origins = ["app://obsidian.md", "capacitor://localhost", "http://localhost"]; - for (const org of origins) { - const rr = await requestToCouchDB(this.editingSettings.couchDB_URI, this.editingSettings.couchDB_USER, this.editingSettings.couchDB_PASSWORD, org); - const responseHeaders = Object.fromEntries(Object.entries(rr.headers) - .map((e) => { - e[0] = `${e[0]}`.toLowerCase(); - return e; - })); - addResult(`Origin check:${org}`); - if (responseHeaders["access-control-allow-credentials"] != "true") { - addResult("❗ CORS is not allowing credentials"); - } else { - addResult("βœ” CORS credentials OK"); - } - if (responseHeaders["access-control-allow-origin"] != org) { - addResult(`❗ CORS Origin is unmatched:${origin}->${responseHeaders["access-control-allow-origin"]}`); - } else { - addResult("βœ” CORS origin OK"); - } - } - addResult("--Done--", ["ob-btn-config-head"]); - addResult("If you have some trouble with Connection-check even though all Config-check has been passed, please check your reverse proxy's configuration.", ["ob-btn-config-info"]); - Logger(`Checking configuration done`, LOG_LEVEL_INFO); - } catch (ex: any) { - if (ex?.status == 401) { - addResult(`❗ Access forbidden.`); - addResult(`We could not continue the test.`); - Logger(`Checking configuration done`, LOG_LEVEL_INFO); - } else { - Logger(`Checking configuration failed`, LOG_LEVEL_NOTICE); - Logger(ex); - } - } - }; - await checkConfig(); - }) - ); - const checkResultDiv = this.createEl(containerRemoteDatabaseEl, "div", { - text: "", - }); - - new Setting(containerRemoteDatabaseEl) - .setName("Apply Settings") - .setClass("wizardHidden") - .addApplyButton(["remoteType", "couchDB_URI", "couchDB_USER", "couchDB_PASSWORD", "couchDB_DBNAME"]) - .addOnUpdate(onlyOnCouchDB) - }, onlyOnCouchDB); - - this.createEl(containerRemoteDatabaseEl, "h4", { text: "Notification" }).addClass("wizardHidden") - new Setting(containerRemoteDatabaseEl).autoWireNumeric("notifyThresholdOfRemoteStorageSize", {}).setClass("wizardHidden"); - - - this.createEl(containerRemoteDatabaseEl, "h4", { text: "Effective Storage Using" }).addClass("wizardHidden") - - new Setting(containerRemoteDatabaseEl).autoWireToggle("useEden").setClass("wizardHidden"); - const onlyUsingEden = visibleOnly(() => this.isConfiguredAs("useEden", true)); - new Setting(containerRemoteDatabaseEl).autoWireNumeric("maxChunksInEden", { onUpdate: onlyUsingEden }).setClass("wizardHidden"); - new Setting(containerRemoteDatabaseEl).autoWireNumeric("maxTotalLengthInEden", { onUpdate: onlyUsingEden }).setClass("wizardHidden"); - new Setting(containerRemoteDatabaseEl).autoWireNumeric("maxAgeInEden", { onUpdate: onlyUsingEden }).setClass("wizardHidden"); - - new Setting(containerRemoteDatabaseEl).autoWireToggle("enableCompression").setClass("wizardHidden"); - - this.createEl(containerRemoteDatabaseEl, "h4", { text: "Confidentiality" }); - - new Setting(containerRemoteDatabaseEl) - .autoWireToggle("encrypt", { holdValue: true }) - - const isEncryptEnabled = visibleOnly(() => this.isConfiguredAs("encrypt", true)) - - new Setting(containerRemoteDatabaseEl) - .autoWireText("passphrase", { holdValue: true, isPassword: true, onUpdate: isEncryptEnabled }) - - new Setting(containerRemoteDatabaseEl) - .autoWireToggle("usePathObfuscation", { holdValue: true, onUpdate: isEncryptEnabled }) - new Setting(containerRemoteDatabaseEl) - .autoWireToggle("useDynamicIterationCount", { holdValue: true, onUpdate: isEncryptEnabled }).setClass("wizardHidden"); - - new Setting(containerRemoteDatabaseEl) - .setName("Apply") - .setDesc("Apply encryption settings") - .setClass("wizardHidden") - .addButton((button) => - button - .setButtonText("Just apply") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await applyEncryption(false); - }) - ) - .addButton((button) => - button - .setButtonText("Apply and fetch") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await rebuildDB("localOnly"); - }) - ) - .addButton((button) => - button - .setButtonText("Apply and rebuild") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await rebuildDB("rebuildBothByThisDevice"); - }) - ) - .addOnUpdate(() => ({ - isCta: this.isSomeDirty(["passphrase", "useDynamicIterationCount", "usePathObfuscation", "encrypt"]), - disabled: !this.isSomeDirty(["passphrase", "useDynamicIterationCount", "usePathObfuscation", "encrypt"]), - })) + // E2EE Function const checkWorkingPassphrase = async (): Promise => { if (this.editingSettings.remoteType == REMOTE_MINIO) return true; const settingForCheck: RemoteDBSettings = { ...this.editingSettings, - // encrypt: encrypt, - // passphrase: passphrase, - // useDynamicIterationCount: useDynamicIterationCount, }; - const replicator = this.plugin.getReplicator(); + const replicator = this.plugin.getNewReplicator( + settingForCheck + ); if (!(replicator instanceof LiveSyncCouchDBReplicator)) return true; const db = await replicator.connectRemoteCouchDBWithSetting(settingForCheck, this.plugin.isMobile, true); if (typeof db === "string") { - Logger("Could not connect to the database.", LOG_LEVEL_NOTICE); + Logger(`ERROR: Failed to check passphrase with the remote server: \n${db}.`, LOG_LEVEL_NOTICE); return false; } else { if (await checkSyncInfo(db.db)) { // Logger("Database connected", LOG_LEVEL_NOTICE); return true; } else { - Logger("Failed to read remote database", LOG_LEVEL_NOTICE); + Logger("ERROR: Passphrase is not compatible with the remote server! Please confirm it again!", LOG_LEVEL_NOTICE); return false; } } - }; - const applyEncryption = async (sendToServer: boolean) => { + } + const isPassphraseValid = async () => { if (this.editingSettings.encrypt && this.editingSettings.passphrase == "") { Logger("If you enable encryption, you have to set the passphrase", LOG_LEVEL_NOTICE); - return; + return false; } if (this.editingSettings.encrypt && !(await testCrypt())) { - Logger("WARNING! Your device does not support encryption.", LOG_LEVEL_NOTICE); - return; + Logger("Your device does not support encryption.", LOG_LEVEL_NOTICE); + return false; } - if (!(await checkWorkingPassphrase()) && !sendToServer) { - return; - } - if (!this.editingSettings.encrypt) { - this.editingSettings.passphrase = ""; - } - // this.applyAllSettings(); - this.saveAllDirtySettings(); - this.plugin.addOnSetup.suspendAllSync(); - this.plugin.addOnSetup.suspendExtraSync(); - this.reloadAllSettings(); - // this.editingSettings.encrypt = encrypt; - // this.editingSettings.passphrase = passphrase; - // this.editingSettings.useDynamicIterationCount = useDynamicIterationCount; - // this.editingSettings.usePathObfuscation = usePathObfuscation; - this.editingSettings.isConfigured = true; - await this.saveAllDirtySettings(); - if (sendToServer) { - await this.plugin.addOnSetup.rebuildRemote() - } else { - await this.plugin.markRemoteResolved(); - await this.plugin.replicate(true); - } - }; + return true; + } const rebuildDB = async (method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks") => { if (this.editingSettings.encrypt && this.editingSettings.passphrase == "") { @@ -1342,1274 +765,1810 @@ However, your report is needed to stabilise this. I appreciate you for your grea this.closeSetting(); await delay(2000); await performRebuildDB(this.plugin, method); - // this.resetEditingSettings(); } + // Panes + addPane(containerEl, "Update Information", "πŸ’¬", 100, false).then((paneEl) => { + const informationDivEl = this.createEl(paneEl, "div", { text: "" }); - new Setting(containerRemoteDatabaseEl) - .setClass("wizardOnly") - .addButton((button) => - button - .setButtonText("Next") - .setCta() - .setDisabled(false) - .onClick(() => { - if (!this.editingSettings.encrypt) { - this.editingSettings.passphrase = ""; - } - if (isCloudantURI(this.editingSettings.couchDB_URI)) { - this.editingSettings = { ...this.editingSettings, ...PREFERRED_SETTING_CLOUDANT }; - } else if (this.editingSettings.remoteType == REMOTE_MINIO) { - this.editingSettings = { ...this.editingSettings, ...PREFERRED_JOURNAL_SYNC }; - } else { - this.editingSettings = { ...this.editingSettings, ...PREFERRED_SETTING_SELF_HOSTED }; - } - changeDisplay("30") - }) - ) - ; - - addScreenElement("0", containerRemoteDatabaseEl); - - const containerGeneralSettingsEl = containerEl.createDiv(); - this.createEl(containerGeneralSettingsEl, "h3", { text: "General Settings" }); - - this.createEl(containerGeneralSettingsEl, "h4", { text: "Appearance" }); - - - const languages = Object.fromEntries([["", "Default"], ...SUPPORTED_I18N_LANGS.map(e => [e, $t(`lang-${e}`)])]) as Record; - new Setting(containerGeneralSettingsEl).autoWireDropDown( - "displayLanguage", - { - options: languages - } - ) - this.addOnSaved("displayLanguage", () => this.display()); - new Setting(containerGeneralSettingsEl).autoWireToggle("showStatusOnEditor"); - new Setting(containerGeneralSettingsEl).autoWireToggle("showOnlyIconsOnEditor", - { onUpdate: visibleOnly(() => this.isConfiguredAs("showStatusOnEditor", true)) } - ); - new Setting(containerGeneralSettingsEl).autoWireToggle("showStatusOnStatusbar"); - - this.createEl(containerGeneralSettingsEl, "h4", { text: "Logging" }); - - new Setting(containerGeneralSettingsEl).autoWireToggle("lessInformationInLog"); - - new Setting(containerGeneralSettingsEl) - .autoWireToggle("showVerboseLog", { onUpdate: visibleOnly(() => this.isConfiguredAs("lessInformationInLog", false)) }); - - this.createEl(containerGeneralSettingsEl, "h4", { text: "Performance tweaks" }); - - new Setting(containerGeneralSettingsEl) - .autoWireNumeric("hashCacheMaxCount", { clampMin: 10 }); - new Setting(containerGeneralSettingsEl) - .autoWireNumeric("hashCacheMaxAmount", { clampMin: 1 }); - - this.createEl(containerGeneralSettingsEl, "h4", { text: "Share settings via markdown" }); - new Setting(containerGeneralSettingsEl) - .autoWireText("settingSyncFile", { holdValue: true }) - .addApplyButton(["settingSyncFile"]) - - new Setting(containerGeneralSettingsEl) - .autoWireToggle("writeCredentialsForSettingSync"); - - new Setting(containerGeneralSettingsEl) - .autoWireToggle("notifyAllSettingSyncFile") - - this.createEl(containerGeneralSettingsEl, "h4", { text: "Advanced Confidentiality" }); - - const passphrase_options: Record = { - "": "Default", - LOCALSTORAGE: "Use a custom passphrase", - ASK_AT_LAUNCH: "Ask an passphrase at every launch", - } - - new Setting(containerGeneralSettingsEl) - .setName("Encrypting sensitive configuration items") - .autoWireDropDown("configPassphraseStore", { options: passphrase_options, holdValue: true }) - .setClass("wizardHidden"); - - new Setting(containerGeneralSettingsEl) - .autoWireText("configPassphrase", { isPassword: true, holdValue: true }) - .setClass("wizardHidden") - .addOnUpdate(() => ({ - disabled: !this.isConfiguredAs("configPassphraseStore", "LOCALSTORAGE") - })) - new Setting(containerGeneralSettingsEl) - .addApplyButton(["configPassphrase", "configPassphraseStore"]) - .setClass("wizardHidden") - - - addScreenElement("20", containerGeneralSettingsEl); - const containerSyncSettingEl = containerEl.createDiv(); - this.createEl(containerSyncSettingEl, "h3", { text: "Sync Settings" }); - // containerSyncSettingEl.addClass("wizardHidden") - - if (this.editingSettings.versionUpFlash != "") { - const c = this.createEl(containerSyncSettingEl, "div", { - text: this.editingSettings.versionUpFlash, - cls: "op-warn sls-setting-hidden" - }, el => { - this.createEl(el, "button", { text: "I got it and updated." }, (e) => { - e.addClass("mod-cta"); - e.addEventListener("click", async () => { - this.editingSettings.versionUpFlash = ""; - await this.saveAllDirtySettings(); - c.remove(); - }); - }) - }, visibleOnly(() => !this.isConfiguredAs("versionUpFlash", ""))); - } - - this.createEl(containerSyncSettingEl, "div", - { - text: `Please select any preset to complete the wizard.`, - cls: "wizardOnly" - } - ).addClasses(["op-warn-info"]); - - - - const options: Record = this.editingSettings.remoteType == REMOTE_COUCHDB ? { - NONE: "", - LIVESYNC: "LiveSync", - PERIODIC: "Periodic w/ batch", - DISABLE: "Disable all automatic" - } : { NONE: "", PERIODIC: "Periodic w/ batch", DISABLE: "Disable all automatic" }; - - new Setting(containerSyncSettingEl) - .autoWireDropDown("preset", { - options: options, holdValue: true, - }).addButton(button => { - button.setButtonText("Apply"); - button.onClick(async () => { - await this.saveSettings(["preset"]); - }) - }) - - this.addOnSaved("preset", async (currentPreset) => { - if (currentPreset == "") { - Logger("Select any preset.", LOG_LEVEL_NOTICE); - return; - } - const presetAllDisabled = { - batchSave: false, - liveSync: false, - periodicReplication: false, - syncOnSave: false, - syncOnEditorSave: false, - syncOnStart: false, - syncOnFileOpen: false, - syncAfterMerge: false, - } as Partial; - const presetLiveSync = { - ...presetAllDisabled, - liveSync: true - } as Partial; - const presetPeriodic = { - ...presetAllDisabled, - batchSave: true, - periodicReplication: true, - syncOnSave: false, - syncOnEditorSave: false, - syncOnStart: true, - syncOnFileOpen: true, - syncAfterMerge: true, - } as Partial; - - if (currentPreset == "LIVESYNC") { - this.editingSettings = { - ...this.editingSettings, - ...presetLiveSync - } - Logger("Synchronization setting configured as LiveSync.", LOG_LEVEL_NOTICE); - } else if (currentPreset == "PERIODIC") { - this.editingSettings = { - ...this.editingSettings, - ...presetPeriodic - } - Logger("Synchronization setting configured as Periodic sync with batch database update.", LOG_LEVEL_NOTICE); - } else { - Logger("All synchronizations disabled.", LOG_LEVEL_NOTICE); - this.editingSettings = { - ...this.editingSettings, - ...presetAllDisabled - } - } - await this.saveAllDirtySettings(); - await this.plugin.realizeSettingSyncMode(); - if (inWizard) { - this.closeSetting(); - if (!this.editingSettings.isConfigured) { - this.editingSettings.isConfigured = true; + const tmpDiv = createSpan(); + tmpDiv.addClass("sls-header-button"); + tmpDiv.innerHTML = ``; + if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) { + const informationButtonDiv = paneEl.appendChild(tmpDiv); + informationButtonDiv.querySelector("button")?.addEventListener("click", async () => { + this.editingSettings.lastReadUpdates = lastVersion; await this.saveAllDirtySettings(); - await rebuildDB("localOnly"); - // this.resetEditingSettings(); - Logger("All done! Please set up subsequent devices with 'Copy current settings as a new setup URI' and 'Use the copied setup URI'.", LOG_LEVEL_NOTICE); - await this.plugin.addOnSetup.command_copySetupURI(); - } else { - this.plugin.askReload(); - } - } - }) - - this.createEl(containerSyncSettingEl, "h4", { text: "Synchronization Methods" }).addClass("wizardHidden"); - - // const onlyOnLiveSync = visibleOnly(() => this.isConfiguredAs("syncMode", "LIVESYNC")); - const onlyOnNonLiveSync = visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC")); - const onlyOnPeriodic = visibleOnly(() => this.isConfiguredAs("syncMode", "PERIODIC")); - - const optionsSyncMode = this.editingSettings.remoteType == REMOTE_COUCHDB ? { - "ONEVENTS": "On events", - PERIODIC: "Periodic and On events", - "LIVESYNC": "LiveSync" - } : { "ONEVENTS": "On events", PERIODIC: "Periodic and On events" } - - - new Setting(containerSyncSettingEl) - .autoWireDropDown("syncMode", { - //@ts-ignore - options: optionsSyncMode - }) - .setClass("wizardHidden") - this.addOnSaved("syncMode", async (value) => { - // debugger; - this.editingSettings.liveSync = false; - this.editingSettings.periodicReplication = false; - if (value == "LIVESYNC") { - this.editingSettings.liveSync = true; - } else if (value == "PERIODIC") { - this.editingSettings.periodicReplication = true; - } - await this.saveSettings(["liveSync", "periodicReplication"]); - - await this.plugin.realizeSettingSyncMode(); - }) - - new Setting(containerSyncSettingEl) - .autoWireNumeric("periodicReplicationInterval", - { clampMax: 5000, onUpdate: onlyOnPeriodic } - ).setClass("wizardHidden") - - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("syncOnSave", { onUpdate: onlyOnNonLiveSync }) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("syncOnEditorSave", { onUpdate: onlyOnNonLiveSync }) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("syncOnFileOpen", { onUpdate: onlyOnNonLiveSync }) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync }) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("syncAfterMerge", { onUpdate: onlyOnNonLiveSync }) - this.createEl(containerSyncSettingEl, "h4", { text: "Deletions propagation" }).addClass("wizardHidden") - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("trashInsteadDelete") - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("doNotDeleteFolder") - - this.createEl(containerSyncSettingEl, "h4", { text: "Conflict resolution" }).addClass("wizardHidden"); - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("resolveConflictsByNewerFile") - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("checkConflictOnlyOnOpen") - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("showMergeDialogOnlyOnActive") - this.createEl(containerSyncSettingEl, "h4", { text: "Compatibility" }).addClass("wizardHidden"); - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("disableMarkdownAutoMerge") - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("writeDocumentsIfConflicted") - - this.createEl(containerSyncSettingEl, "h4", { text: "Hidden files" }).addClass("wizardHidden"); - const LABEL_ENABLED = "πŸ” : Enabled"; - const LABEL_DISABLED = "⏹️ : Disabled" - - const hiddenFileSyncSetting = new Setting(containerSyncSettingEl) - .setName("Hidden file synchronization").setClass("wizardHidden") - const hiddenFileSyncSettingEl = hiddenFileSyncSetting.settingEl - const hiddenFileSyncSettingDiv = hiddenFileSyncSettingEl.createDiv(""); - hiddenFileSyncSettingDiv.innerText = this.editingSettings.syncInternalFiles ? LABEL_ENABLED : LABEL_DISABLED; - - if (this.editingSettings.syncInternalFiles) { - new Setting(containerSyncSettingEl) - .setName("Disable Hidden files sync") - .setClass("wizardHidden") - .addButton((button) => { - button.setButtonText("Disable") - .onClick(async () => { - this.editingSettings.syncInternalFiles = false; - await this.saveAllDirtySettings(); - this.display(); - }) - }) - } else { - - new Setting(containerSyncSettingEl) - .setName("Enable Hidden files sync") - .setClass("wizardHidden") - .addButton((button) => { - button.setButtonText("Merge") - .onClick(async () => { - this.closeSetting() - // this.resetEditingSettings(); - await this.plugin.addOnSetup.configureHiddenFileSync("MERGE"); - }) - }) - .addButton((button) => { - button.setButtonText("Fetch") - .onClick(async () => { - this.closeSetting() - // this.resetEditingSettings(); - await this.plugin.addOnSetup.configureHiddenFileSync("FETCH"); - }) - }) - .addButton((button) => { - button.setButtonText("Overwrite") - .onClick(async () => { - this.closeSetting() - // this.resetEditingSettings(); - await this.plugin.addOnSetup.configureHiddenFileSync("OVERWRITE"); - }) + informationButtonDiv.remove(); }); - } - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("syncInternalFilesBeforeReplication", - { onUpdate: visibleOnly(() => this.isConfiguredAs("watchInternalFileChanges", false)) } - ) - - // } - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("syncInternalFilesInterval", { clampMin: 10, acceptZero: true }) - - const defaultSkipPattern = "\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/"; - const defaultSkipPatternXPlat = defaultSkipPattern + ",\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$"; - - const pat = this.editingSettings.syncInternalFilesIgnorePatterns.split(",").map(x => x.trim()).filter(x => x != ""); - const patSetting = new Setting(containerSyncSettingEl) - .setName("Hidden files ignore patterns") - .setClass("wizardHidden") - .setDesc(""); - - new MultipleRegExpControl( - { - target: patSetting.controlEl, - props: { - patterns: pat, originals: [...pat], apply: async (newPatterns: string[]) => { - this.editingSettings.syncInternalFilesIgnorePatterns = newPatterns.map(e => e.trim()).filter(e => e != "").join(", "); - await this.saveAllDirtySettings(); - this.display(); - } - } } - ) - const addDefaultPatterns = async (patterns: string) => { - const oldList = this.editingSettings.syncInternalFilesIgnorePatterns.split(",").map(x => x.trim()).filter(x => x != ""); - const newList = patterns.split(",").map(x => x.trim()).filter(x => x != ""); - const allSet = new Set([...oldList, ...newList]); - this.editingSettings.syncInternalFilesIgnorePatterns = [...allSet].join(", "); - await this.saveAllDirtySettings(); - this.display(); - } + MarkdownRenderer.render(this.plugin.app, updateInformation, informationDivEl, "/", this.plugin); + }); - new Setting(containerSyncSettingEl) - .setName("Add default patterns") - .setClass("wizardHidden") - .addButton((button) => { - button.setButtonText("Default") - .onClick(async () => { - await addDefaultPatterns(defaultSkipPattern); + addPane(containerEl, "Setup", "πŸ§™β€β™‚οΈ", 110, false).then((paneEl) => { + addPanel(paneEl, "Quick Setup").then(paneEl => { + new Setting(paneEl) + .setName("Use the copied setup URI") + .setDesc("To setup Self-hosted LiveSync, this method is the most preferred one.") + .addButton((text) => { + text.setButtonText("Use").onClick(async () => { + this.closeSetting(); + await this.plugin.addOnSetup.command_openSetupURI(); + }) }) - }).addButton((button) => { - button.setButtonText("Cross-platform") - .onClick(async () => { - await addDefaultPatterns(defaultSkipPatternXPlat); + + new Setting(paneEl) + .setName("Minimal setup") + .addButton((text) => { + text.setButtonText("Start").onClick(async () => { + this.editingSettings.liveSync = false; + this.editingSettings.periodicReplication = false; + this.editingSettings.syncOnSave = false; + this.editingSettings.syncOnEditorSave = false; + this.editingSettings.syncOnStart = false; + this.editingSettings.syncOnFileOpen = false; + this.editingSettings.syncAfterMerge = false; + this.plugin.replicator.closeReplication(); + await this.saveAllDirtySettings(); + containerEl.addClass("isWizard"); + this.inWizard = true; + changeDisplay("0") + }) }) - }) + new Setting(paneEl) + .setName("Enable LiveSync on this device as the setup was completed manually") + .addOnUpdate(visibleOnly(() => !this.isConfiguredAs("isConfigured", true))) + .addButton((text) => { + text.setButtonText("Enable").onClick(async () => { + this.editingSettings.isConfigured = true; + await this.saveAllDirtySettings(); + this.plugin.askReload(); + }) + }) + }); + + addPanel(paneEl, "To setup the other devices", undefined, visibleOnly(() => this.isConfiguredAs("isConfigured", true))).then(paneEl => { + new Setting(paneEl) + .setName("Copy current settings as a new setup URI") + .addButton((text) => { + text.setButtonText("Copy").onClick(async () => { + await this.plugin.addOnSetup.command_copySetupURI(); + }) + }) + }); + addPanel(paneEl, "Reset").then(paneEl => { - this.createEl(containerSyncSettingEl, "h4", { text: "Performance tweaks" }).addClass("wizardHidden"); - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("batchSave") - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("batchSaveMinimumDelay", - { - acceptZero: true, - onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)) - } - ) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("batchSaveMaximumDelay", - { - acceptZero: true, - onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)) - } - ) - - - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("customChunkSize", { clampMin: 0 }) - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("readChunksOnline", { onUpdate: onlyOnCouchDB }) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB }) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("sendChunksBulkMaxSize", { clampMax: 100, clampMin: 1, onUpdate: onlyOnCouchDB }) - - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("enableChunkSplitterV2") - - this.createEl(containerSyncSettingEl, "h4", { - text: sanitizeHTMLToDom(`Targets`), - }).addClass("wizardHidden"); - - const syncFilesSetting = new Setting(containerSyncSettingEl) - .setName("Synchronising files") - .setDesc("(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.") - .setClass("wizardHidden") - new MultipleRegExpControl( - { - target: syncFilesSetting.controlEl, - props: { - patterns: this.editingSettings.syncOnlyRegEx.split("|[]|"), - originals: [...this.editingSettings.syncOnlyRegEx.split("|[]|")], - apply: async (newPatterns: string[]) => { - this.editingSettings.syncOnlyRegEx = newPatterns.map((e: string) => e.trim()).filter(e => e != "").join("|[]|"); - await this.saveAllDirtySettings(); - this.display(); - } - } - } - ) - - const nonSyncFilesSetting = new Setting(containerSyncSettingEl) - .setName("Non-Synchronising files") - .setDesc("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.") - .setClass("wizardHidden"); - - new MultipleRegExpControl( - { - target: nonSyncFilesSetting.controlEl, - props: { - patterns: this.editingSettings.syncIgnoreRegEx.split("|[]|"), - originals: [...this.editingSettings.syncIgnoreRegEx.split("|[]|")], - apply: async (newPatterns: string[]) => { - this.editingSettings.syncIgnoreRegEx = newPatterns.map(e => e.trim()).filter(e => e != "").join("|[]|"); - await this.saveAllDirtySettings(); - this.display(); - } - } - } - ) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 }) - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("useIgnoreFiles") - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireTextArea("ignoreFiles", { onUpdate: visibleOnly(() => this.isConfiguredAs("useIgnoreFiles", true)) }); - - this.createEl(containerSyncSettingEl, "h4", { - text: sanitizeHTMLToDom(`Advanced settings`), - }, undefined, onlyOnCouchDB).addClass("wizardHidden"); - - this.createEl(containerSyncSettingEl, "div", { - text: `If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.`, - }, undefined, onlyOnCouchDB).addClass("wizardHidden"); - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("batch_size", { clampMin: 2, onUpdate: onlyOnCouchDB }) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("batches_limit", { clampMin: 2, onUpdate: onlyOnCouchDB }) - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireToggle("useTimeouts", { onUpdate: onlyOnCouchDB }); - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("concurrencyOfReadChunksOnline", { clampMin: 10, onUpdate: onlyOnCouchDB }) - - new Setting(containerSyncSettingEl) - .setClass("wizardHidden") - .autoWireNumeric("minimumIntervalOfReadChunksOnline", { clampMin: 10, onUpdate: onlyOnCouchDB }) - - addScreenElement("30", containerSyncSettingEl); - const containerHatchEl = containerEl.createDiv(); - - this.createEl(containerHatchEl, "h3", { text: "Hatch" }); - - - new Setting(containerHatchEl) - .setName("Make report to inform the issue") - .addButton((button) => - button - .setButtonText("Make report") - .setDisabled(false) - .onClick(async () => { - let responseConfig: any = {}; - const REDACTED = "𝑅𝐸𝐷𝐴𝐢𝑇𝐸𝐷"; - if (this.editingSettings.remoteType == REMOTE_COUCHDB) { - try { - const r = await requestToCouchDB(this.editingSettings.couchDB_URI, this.editingSettings.couchDB_USER, this.editingSettings.couchDB_PASSWORD, window.origin); - - Logger(JSON.stringify(r.json, null, 2)); - - responseConfig = r.json; - responseConfig["couch_httpd_auth"].secret = REDACTED; - responseConfig["couch_httpd_auth"].authentication_db = REDACTED; - responseConfig["couch_httpd_auth"].authentication_redirect = REDACTED; - responseConfig["couchdb"].uuid = REDACTED; - responseConfig["admins"] = REDACTED; - - } catch (ex) { - responseConfig = "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour." + new Setting(paneEl) + .setName("Discard existing settings and databases") + .addButton((text) => { + text.setButtonText("Discard").onClick(async () => { + if (await askYesNo(this.plugin.app, "Do you really want to discard existing settings and databases?") == "yes") { + this.editingSettings = { ...this.editingSettings, ...DEFAULT_SETTINGS }; + await this.plugin.saveSettingData(); + await this.plugin.resetLocalDatabase(); + // await this.plugin.initializeDatabase(); + this.plugin.askReload(); } - } else if (this.editingSettings.remoteType == REMOTE_MINIO) { - responseConfig = "Object Storage Synchronisation"; - // + }).setWarning() + }).addOnUpdate(visibleOnly(() => this.isConfiguredAs("isConfigured", true))) + // } + }); + addPanel(paneEl, "Enable extra and advanced features").then((paneEl) => { + new Setting(paneEl) + .autoWireToggle("useAdvancedMode") + + new Setting(paneEl) + .autoWireToggle("usePowerUserMode") + new Setting(paneEl) + .autoWireToggle("useEdgeCaseMode") + + this.addOnSaved("useAdvancedMode", () => this.display()); + this.addOnSaved("usePowerUserMode", () => this.display()); + this.addOnSaved("useEdgeCaseMode", () => this.display()); + }); + addPanel(paneEl, "Online Tips").then((paneEl) => { + // this.createEl(paneEl, "h3", { text: "Online Tips" }); + const repo = "vrtmrz/obsidian-livesync"; + const topPath = "/docs/troubleshooting.md"; + const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`; + this.createEl(paneEl, "div", "", el => el.innerHTML = `Open in browser`); + const troubleShootEl = this.createEl(paneEl, "div", { text: "", cls: "sls-troubleshoot-preview" }); + const loadMarkdownPage = async (pathAll: string, basePathParam: string = "") => { + troubleShootEl.style.minHeight = troubleShootEl.clientHeight + "px"; + troubleShootEl.empty(); + const fullPath = pathAll.startsWith("/") ? pathAll : `${basePathParam}/${pathAll}`; + + const directoryArr = fullPath.split("/"); + const filename = directoryArr.pop(); + const directly = directoryArr.join("/"); + const basePath = directly; + + let remoteTroubleShootMDSrc = ""; + try { + remoteTroubleShootMDSrc = await request(`${rawRepoURI}${basePath}/${filename}`); + } catch (ex: any) { + remoteTroubleShootMDSrc = "An error occurred!!\n" + ex.toString(); + } + const remoteTroubleShootMD = remoteTroubleShootMDSrc.replace(/\((.*?(.png)|(.jpg))\)/g, `(${rawRepoURI}${basePath}/$1)`) + // Render markdown + await MarkdownRenderer.render(this.plugin.app, ` [Tips and Troubleshooting](${topPath}) [PageTop](${filename})\n\n${remoteTroubleShootMD}`, troubleShootEl, `${rawRepoURI}`, this.plugin); + // Menu + troubleShootEl.querySelector(".sls-troubleshoot-anchor")?.parentElement?.setCssStyles({ + position: "sticky", + top: "-1em", + backgroundColor: "var(--modal-background)" + }); + // Trap internal links. + troubleShootEl.querySelectorAll("a.internal-link").forEach((anchorEl) => { + anchorEl.addEventListener("click", async (evt) => { + const uri = anchorEl.getAttr("data-href"); + if (!uri) return; + if (uri.startsWith("#")) { + evt.preventDefault(); + const elements = Array.from(troubleShootEl.querySelectorAll("[data-heading]")) + const p = elements.find(e => e.getAttr("data-heading")?.toLowerCase().split(" ").join("-") == uri.substring(1).toLowerCase()); + if (p) { + p.setCssStyles({ scrollMargin: "3em" }); + p.scrollIntoView({ behavior: "instant", block: "start" }); + } + } else { + evt.preventDefault(); + await loadMarkdownPage(uri, basePath); + troubleShootEl.setCssStyles({ scrollMargin: "1em" }); + troubleShootEl.scrollIntoView({ behavior: "instant", block: "start" }); + } + }) + }) + troubleShootEl.style.minHeight = ""; + } + loadMarkdownPage(topPath); + }); + }); + addPane(containerEl, "General Settings", "βš™οΈ", 20, false).then((paneEl) => { + addPanel(paneEl, "Appearance").then((paneEl) => { + const languages = Object.fromEntries([["", "Default"], ...SUPPORTED_I18N_LANGS.map(e => [e, $t(`lang-${e}`)])]) as Record; + new Setting(paneEl).autoWireDropDown( + "displayLanguage", + { + options: languages + } + ) + this.addOnSaved("displayLanguage", () => this.display()); + new Setting(paneEl).autoWireToggle("showStatusOnEditor"); + new Setting(paneEl).autoWireToggle("showOnlyIconsOnEditor", + { onUpdate: visibleOnly(() => this.isConfiguredAs("showStatusOnEditor", true)) } + ); + new Setting(paneEl).autoWireToggle("showStatusOnStatusbar"); + }); + addPanel(paneEl, "Logging").then((paneEl) => { + + new Setting(paneEl).autoWireToggle("lessInformationInLog"); + + new Setting(paneEl) + .autoWireToggle("showVerboseLog", { onUpdate: visibleOnly(() => this.isConfiguredAs("lessInformationInLog", false)) }); + }); + + }) + addPane(containerEl, "Remote Configuration", "πŸ›°οΈ", 0, false).then((paneEl) => { + addPanel(paneEl, "Remote Server").then((paneEl) => { + // const containerRemoteDatabaseEl = containerEl.createDiv(); + new Setting(paneEl) + .autoWireDropDown("remoteType", { + holdValue: true, options: { + [REMOTE_COUCHDB]: "CouchDB", [REMOTE_MINIO]: "Minio,S3,R2", + }, onUpdate: enableOnlySyncDisabled + }) + + + + addPanel(paneEl, "Minio,S3,R2", undefined, onlyOnMinIO).then(paneEl => { + + const syncWarnMinio = this.createEl(paneEl, "div", { + text: "" + }); + const ObjectStorageMessage = `Kindly notice: this is a pretty experimental feature, hence we have some limitations. +- Append only architecture. It will not shrink used storage if we do not perform a rebuild. +- A bit fragile. +- During the first synchronization, the entire history to date will be transferred. For this reason, it is preferable to do this while connected to a Wi-Fi network. +- From the second, we always transfer only differences. + +However, your report is needed to stabilise this. I appreciate you for your great dedication. +`; + + MarkdownRenderer.render(this.plugin.app, ObjectStorageMessage, syncWarnMinio, "/", this.plugin); + syncWarnMinio.addClass("op-warn-info"); + + new Setting(paneEl).autoWireText("endpoint", { holdValue: true }) + new Setting(paneEl).autoWireText("accessKey", { holdValue: true }); + + new Setting(paneEl).autoWireText("secretKey", { holdValue: true, isPassword: true }); + + new Setting(paneEl).autoWireText("region", { holdValue: true }); + + new Setting(paneEl).autoWireText("bucket", { holdValue: true }); + + new Setting(paneEl).autoWireToggle("useCustomRequestHandler", { holdValue: true }); + new Setting(paneEl) + .setName("Test Connection") + .addButton((button) => + button + .setButtonText("Test") + .setDisabled(false) + .onClick(async () => { + await this.testConnection(this.editingSettings); + }) + ); + new Setting(paneEl) + .setName("Apply Settings") + .setClass("wizardHidden") + .addApplyButton(["remoteType", "endpoint", "region", "accessKey", "secretKey", "bucket", "useCustomRequestHandler"]) + .addOnUpdate(onlyOnMinIO) + + }); + + + addPanel(paneEl, "CouchDB", undefined, onlyOnCouchDB).then(paneEl => { + if (this.plugin.isMobile) { + this.createEl(paneEl, "div", { + text: `Configured as using non-HTTPS. We cannot connect to the remote. Please set up the credentials and use HTTPS for the remote URI.`, + }, undefined, visibleOnly(() => !this.editingSettings.couchDB_URI.startsWith("https://"))) + .addClass("op-warn"); + } else { + this.createEl(paneEl, "div", { + text: `Configured as using non-HTTPS. We might fail on mobile devices.` + }, undefined, visibleOnly(() => !this.editingSettings.couchDB_URI.startsWith("https://"))) + .addClass("op-warn-info"); + } + + this.createEl(paneEl, "div", { text: `These settings are kept locked while any synchronization options are enabled. Disable these options in the "Sync Settings" tab to unlock.` }, + undefined, visibleOnly(() => isAnySyncEnabled()) + ).addClass("sls-setting-hidden"); + + new Setting(paneEl).autoWireText("couchDB_URI", { holdValue: true, onUpdate: enableOnlySyncDisabled }); + new Setting(paneEl).autoWireText("couchDB_USER", { holdValue: true, onUpdate: enableOnlySyncDisabled }); + new Setting(paneEl).autoWireText("couchDB_PASSWORD", { holdValue: true, isPassword: true, onUpdate: enableOnlySyncDisabled }); + new Setting(paneEl).autoWireText("couchDB_DBNAME", { holdValue: true, onUpdate: enableOnlySyncDisabled }); + + + new Setting(paneEl) + .setName("Test Database Connection") + .setClass("wizardHidden") + .setDesc("Open database connection. If the remote database is not found and you have the privilege to create a database, the database will be created.") + .addButton((button) => + button + .setButtonText("Test") + .setDisabled(false) + .onClick(async () => { + await this.testConnection(); + }) + ); + + new Setting(paneEl) + .setName("Check and fix database configuration") + .setDesc("Check the database configuration, and fix if there are any problems.") + .addButton((button) => + button + .setButtonText("Check") + .setDisabled(false) + .onClick(async () => { + const checkConfig = async () => { + Logger(`Checking database configuration`, LOG_LEVEL_INFO); + + const emptyDiv = createDiv(); + emptyDiv.innerHTML = ""; + checkResultDiv.replaceChildren(...[emptyDiv]); + const addResult = (msg: string, classes?: string[]) => { + const tmpDiv = createDiv(); + tmpDiv.addClass("ob-btn-config-fix"); + if (classes) { + tmpDiv.addClasses(classes); + } + tmpDiv.innerHTML = `${msg}`; + checkResultDiv.appendChild(tmpDiv); + }; + try { + + if (isCloudantURI(this.editingSettings.couchDB_URI)) { + Logger("This feature cannot be used with IBM Cloudant.", LOG_LEVEL_NOTICE); + return; + } + const r = await requestToCouchDB(this.editingSettings.couchDB_URI, this.editingSettings.couchDB_USER, this.editingSettings.couchDB_PASSWORD, window.origin); + const responseConfig = r.json; + + const addConfigFixButton = (title: string, key: string, value: string) => { + const tmpDiv = createDiv(); + tmpDiv.addClass("ob-btn-config-fix"); + tmpDiv.innerHTML = ``; + const x = checkResultDiv.appendChild(tmpDiv); + x.querySelector("button")?.addEventListener("click", async () => { + Logger(`CouchDB Configuration: ${title} -> Set ${key} to ${value}`) + const res = await requestToCouchDB(this.editingSettings.couchDB_URI, this.editingSettings.couchDB_USER, this.editingSettings.couchDB_PASSWORD, undefined, key, value); + if (res.status == 200) { + Logger(`CouchDB Configuration: ${title} successfully updated`, LOG_LEVEL_NOTICE); + checkResultDiv.removeChild(x); + checkConfig(); + } else { + Logger(`CouchDB Configuration: ${title} failed`, LOG_LEVEL_NOTICE); + Logger(res.text, LOG_LEVEL_VERBOSE); + } + }); + }; + addResult("---Notice---", ["ob-btn-config-head"]); + addResult( + "If the server configuration is not persistent (e.g., running on docker), the values set from here will also be volatile. Once you are able to connect, please reflect the settings in the server's local.ini.", + ["ob-btn-config-info"] + ); + + addResult("--Config check--", ["ob-btn-config-head"]); + + // Admin check + // for database creation and deletion + if (!(this.editingSettings.couchDB_USER in responseConfig.admins)) { + addResult(`⚠ You do not have administrative privileges.`); + } else { + addResult("βœ” You have administrative privileges."); + } + // HTTP user-authorization check + if (responseConfig?.chttpd?.require_valid_user != "true") { + addResult("❗ chttpd.require_valid_user is wrong."); + addConfigFixButton("Set chttpd.require_valid_user = true", "chttpd/require_valid_user", "true"); + } else { + addResult("βœ” chttpd.require_valid_user is ok."); + } + if (responseConfig?.chttpd_auth?.require_valid_user != "true") { + addResult("❗ chttpd_auth.require_valid_user is wrong."); + addConfigFixButton("Set chttpd_auth.require_valid_user = true", "chttpd_auth/require_valid_user", "true"); + } else { + addResult("βœ” chttpd_auth.require_valid_user is ok."); + } + // HTTPD check + // Check Authentication header + if (!responseConfig?.httpd["WWW-Authenticate"]) { + addResult("❗ httpd.WWW-Authenticate is missing"); + addConfigFixButton("Set httpd.WWW-Authenticate", "httpd/WWW-Authenticate", 'Basic realm="couchdb"'); + } else { + addResult("βœ” httpd.WWW-Authenticate is ok."); + } + if (responseConfig?.httpd?.enable_cors != "true") { + addResult("❗ httpd.enable_cors is wrong"); + addConfigFixButton("Set httpd.enable_cors", "httpd/enable_cors", "true"); + } else { + addResult("βœ” httpd.enable_cors is ok."); + } + // If the server is not cloudant, configure request size + if (!isCloudantURI(this.editingSettings.couchDB_URI)) { + // REQUEST SIZE + if (Number(responseConfig?.chttpd?.max_http_request_size ?? 0) < 4294967296) { + addResult("❗ chttpd.max_http_request_size is low)"); + addConfigFixButton("Set chttpd.max_http_request_size", "chttpd/max_http_request_size", "4294967296"); + } else { + addResult("βœ” chttpd.max_http_request_size is ok."); + } + if (Number(responseConfig?.couchdb?.max_document_size ?? 0) < 50000000) { + addResult("❗ couchdb.max_document_size is low)"); + addConfigFixButton("Set couchdb.max_document_size", "couchdb/max_document_size", "50000000"); + } else { + addResult("βœ” couchdb.max_document_size is ok."); + } + } + // CORS check + // checking connectivity for mobile + if (responseConfig?.cors?.credentials != "true") { + addResult("❗ cors.credentials is wrong"); + addConfigFixButton("Set cors.credentials", "cors/credentials", "true"); + } else { + addResult("βœ” cors.credentials is ok."); + } + const ConfiguredOrigins = ((responseConfig?.cors?.origins ?? "") + "").split(","); + if ( + responseConfig?.cors?.origins == "*" || + (ConfiguredOrigins.indexOf("app://obsidian.md") !== -1 && ConfiguredOrigins.indexOf("capacitor://localhost") !== -1 && ConfiguredOrigins.indexOf("http://localhost") !== -1) + ) { + addResult("βœ” cors.origins is ok."); + } else { + addResult("❗ cors.origins is wrong"); + addConfigFixButton("Set cors.origins", "cors/origins", "app://obsidian.md,capacitor://localhost,http://localhost"); + } + addResult("--Connection check--", ["ob-btn-config-head"]); + addResult(`Current origin:${window.location.origin}`); + + // Request header check + const origins = ["app://obsidian.md", "capacitor://localhost", "http://localhost"]; + for (const org of origins) { + const rr = await requestToCouchDB(this.editingSettings.couchDB_URI, this.editingSettings.couchDB_USER, this.editingSettings.couchDB_PASSWORD, org); + const responseHeaders = Object.fromEntries(Object.entries(rr.headers) + .map((e) => { + e[0] = `${e[0]}`.toLowerCase(); + return e; + })); + addResult(`Origin check:${org}`); + if (responseHeaders["access-control-allow-credentials"] != "true") { + addResult("❗ CORS is not allowing credentials"); + } else { + addResult("βœ” CORS credentials OK"); + } + if (responseHeaders["access-control-allow-origin"] != org) { + addResult(`❗ CORS Origin is unmatched:${origin}->${responseHeaders["access-control-allow-origin"]}`); + } else { + addResult("βœ” CORS origin OK"); + } + } + addResult("--Done--", ["ob-btn-config-head"]); + addResult("If you have some trouble with Connection-check even though all Config-check has been passed, please check your reverse proxy's configuration.", ["ob-btn-config-info"]); + Logger(`Checking configuration done`, LOG_LEVEL_INFO); + } catch (ex: any) { + if (ex?.status == 401) { + addResult(`❗ Access forbidden.`); + addResult(`We could not continue the test.`); + Logger(`Checking configuration done`, LOG_LEVEL_INFO); + } else { + Logger(`Checking configuration failed`, LOG_LEVEL_NOTICE); + Logger(ex); + } + } + }; + await checkConfig(); + }) + ); + const checkResultDiv = this.createEl(paneEl, "div", { + text: "", + }); + + new Setting(paneEl) + .setName("Apply Settings") + .setClass("wizardHidden") + .addApplyButton(["remoteType", "couchDB_URI", "couchDB_USER", "couchDB_PASSWORD", "couchDB_DBNAME"]) + .addOnUpdate(onlyOnCouchDB) + }); + }); + addPanel(paneEl, "Notification").then((paneEl) => { + paneEl.addClass("wizardHidden") + new Setting(paneEl).autoWireNumeric("notifyThresholdOfRemoteStorageSize", {}).setClass("wizardHidden"); + }); + + addPanel(paneEl, "Confidentiality").then((paneEl) => { + + new Setting(paneEl) + .autoWireToggle("encrypt", { holdValue: true }) + + const isEncryptEnabled = visibleOnly(() => this.isConfiguredAs("encrypt", true)) + + new Setting(paneEl) + .autoWireText("passphrase", { holdValue: true, isPassword: true, onUpdate: isEncryptEnabled }) + + new Setting(paneEl) + .autoWireToggle("usePathObfuscation", { holdValue: true, onUpdate: isEncryptEnabled }) + new Setting(paneEl) + .autoWireToggle("useDynamicIterationCount", { holdValue: true, onUpdate: isEncryptEnabled }).setClass("wizardHidden"); + + }); + new Setting(paneEl) + .setClass("wizardOnly") + .addButton((button) => + button + .setButtonText("Next") + .setCta() + .setDisabled(false) + .onClick(() => { + if (!this.editingSettings.encrypt) { + this.editingSettings.passphrase = ""; + } + if (isCloudantURI(this.editingSettings.couchDB_URI)) { + this.editingSettings = { ...this.editingSettings, ...PREFERRED_SETTING_CLOUDANT }; + } else if (this.editingSettings.remoteType == REMOTE_MINIO) { + this.editingSettings = { ...this.editingSettings, ...PREFERRED_JOURNAL_SYNC }; + } else { + this.editingSettings = { ...this.editingSettings, ...PREFERRED_SETTING_SELF_HOSTED }; + } + changeDisplay("30") + }) + ) + ; + }); + addPane(containerEl, "Sync Settings", "πŸ”„", 30, false).then((paneEl) => { + if (this.editingSettings.versionUpFlash != "") { + const c = this.createEl(paneEl, "div", { + text: this.editingSettings.versionUpFlash, + cls: "op-warn sls-setting-hidden" + }, el => { + this.createEl(el, "button", { text: "I got it and updated." }, (e) => { + e.addClass("mod-cta"); + e.addEventListener("click", async () => { + this.editingSettings.versionUpFlash = ""; + await this.saveAllDirtySettings(); + c.remove(); + }); + }) + }, visibleOnly(() => !this.isConfiguredAs("versionUpFlash", ""))); + } + + this.createEl(paneEl, "div", + { + text: `Please select any preset to complete the wizard.`, + cls: "wizardOnly" + } + ).addClasses(["op-warn-info"]); + + + addPanel(paneEl, "Synchronization Preset").then((paneEl) => { + + const options: Record = this.editingSettings.remoteType == REMOTE_COUCHDB ? { + NONE: "", + LIVESYNC: "LiveSync", + PERIODIC: "Periodic w/ batch", + DISABLE: "Disable all automatic" + } : { NONE: "", PERIODIC: "Periodic w/ batch", DISABLE: "Disable all automatic" }; + + new Setting(paneEl) + .autoWireDropDown("preset", { + options: options, holdValue: true, + }).addButton(button => { + button.setButtonText("Apply"); + button.onClick(async () => { + await this.saveSettings(["preset"]); + }) + }) + + this.addOnSaved("preset", async (currentPreset) => { + if (currentPreset == "") { + Logger("Select any preset.", LOG_LEVEL_NOTICE); + return; + } + const presetAllDisabled = { + batchSave: false, + liveSync: false, + periodicReplication: false, + syncOnSave: false, + syncOnEditorSave: false, + syncOnStart: false, + syncOnFileOpen: false, + syncAfterMerge: false, + } as Partial; + const presetLiveSync = { + ...presetAllDisabled, + liveSync: true + } as Partial; + const presetPeriodic = { + ...presetAllDisabled, + batchSave: true, + periodicReplication: true, + syncOnSave: false, + syncOnEditorSave: false, + syncOnStart: true, + syncOnFileOpen: true, + syncAfterMerge: true, + } as Partial; + + if (currentPreset == "LIVESYNC") { + this.editingSettings = { + ...this.editingSettings, + ...presetLiveSync } - const pluginConfig = JSON.parse(JSON.stringify(this.editingSettings)) as ObsidianLiveSyncSettings; - pluginConfig.couchDB_DBNAME = REDACTED; - pluginConfig.couchDB_PASSWORD = REDACTED; - const scheme = pluginConfig.couchDB_URI.startsWith("http:") ? "(HTTP)" : (pluginConfig.couchDB_URI.startsWith("https:")) ? "(HTTPS)" : "" - pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) ? "cloudant" : `self-hosted${scheme}`; - pluginConfig.couchDB_USER = REDACTED; - pluginConfig.passphrase = REDACTED; - pluginConfig.encryptedPassphrase = REDACTED; - pluginConfig.encryptedCouchDBConnection = REDACTED; - pluginConfig.accessKey = REDACTED; - pluginConfig.secretKey = REDACTED; - pluginConfig.region = `${REDACTED}(${pluginConfig.region.length} letters)`; - pluginConfig.bucket = `${REDACTED}(${pluginConfig.bucket.length} letters)`; - pluginConfig.pluginSyncExtendedSetting = {}; - const endpoint = pluginConfig.endpoint; - if (endpoint == "") { - pluginConfig.endpoint = "Not configured or AWS"; + Logger("Synchronization setting configured as LiveSync.", LOG_LEVEL_NOTICE); + } else if (currentPreset == "PERIODIC") { + this.editingSettings = { + ...this.editingSettings, + ...presetPeriodic + } + Logger("Synchronization setting configured as Periodic sync with batch database update.", LOG_LEVEL_NOTICE); + } else { + Logger("All synchronizations disabled.", LOG_LEVEL_NOTICE); + this.editingSettings = { + ...this.editingSettings, + ...presetAllDisabled + } + } + + if (this.inWizard) { + this.closeSetting(); + this.inWizard = false; + if (!this.editingSettings.isConfigured) { + this.editingSettings.isConfigured = true; + await this.saveAllDirtySettings(); + await this.plugin.realizeSettingSyncMode(); + await rebuildDB("localOnly"); + // this.resetEditingSettings(); + Logger("All done! Please set up subsequent devices with 'Copy current settings as a new setup URI' and 'Use the copied setup URI'.", LOG_LEVEL_NOTICE); + await this.plugin.addOnSetup.command_copySetupURI(); } else { - const endpointScheme = pluginConfig.endpoint.startsWith("http:") ? "(HTTP)" : (pluginConfig.endpoint.startsWith("https:")) ? "(HTTPS)" : ""; - pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`; + if (isNeedRebuildLocal() || isNeedRebuildRemote()) { + await confirmRebuild(); + } else { + await this.saveAllDirtySettings(); + await this.plugin.realizeSettingSyncMode(); + this.plugin.askReload(); + } } - const obsidianInfo = `Navigator: ${navigator.userAgent} + } else { + await this.saveAllDirtySettings(); + await this.plugin.realizeSettingSyncMode(); + } + }) + + }); + addPanel(paneEl, "Synchronization Methods").then((paneEl) => { + paneEl.addClass("wizardHidden"); + + // const onlyOnLiveSync = visibleOnly(() => this.isConfiguredAs("syncMode", "LIVESYNC")); + const onlyOnNonLiveSync = visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC")); + const onlyOnPeriodic = visibleOnly(() => this.isConfiguredAs("syncMode", "PERIODIC")); + + const optionsSyncMode = this.editingSettings.remoteType == REMOTE_COUCHDB ? { + "ONEVENTS": "On events", + PERIODIC: "Periodic and On events", + "LIVESYNC": "LiveSync" + } : { "ONEVENTS": "On events", PERIODIC: "Periodic and On events" } + + + new Setting(paneEl) + .autoWireDropDown("syncMode", { + //@ts-ignore + options: optionsSyncMode + }) + .setClass("wizardHidden") + this.addOnSaved("syncMode", async (value) => { + this.editingSettings.liveSync = false; + this.editingSettings.periodicReplication = false; + if (value == "LIVESYNC") { + this.editingSettings.liveSync = true; + } else if (value == "PERIODIC") { + this.editingSettings.periodicReplication = true; + } + await this.saveSettings(["liveSync", "periodicReplication"]); + + await this.plugin.realizeSettingSyncMode(); + }) + + + new Setting(paneEl) + .autoWireNumeric("periodicReplicationInterval", + { clampMax: 5000, onUpdate: onlyOnPeriodic } + ).setClass("wizardHidden") + + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("syncOnSave", { onUpdate: onlyOnNonLiveSync }) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("syncOnEditorSave", { onUpdate: onlyOnNonLiveSync }) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("syncOnFileOpen", { onUpdate: onlyOnNonLiveSync }) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync }) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("syncAfterMerge", { onUpdate: onlyOnNonLiveSync }) + + }); + + addPanel(paneEl, "Update thinning").then((paneEl) => { + paneEl.addClass("wizardHidden"); + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("batchSave") + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("batchSaveMinimumDelay", + { + acceptZero: true, + onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)) + } + ) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("batchSaveMaximumDelay", + { + acceptZero: true, + onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)) + } + ) + }); + + addPanel(paneEl, "Deletion Propagation", undefined, undefined, LEVEL_ADVANCED).then((paneEl) => { + paneEl.addClass("wizardHidden"); + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("trashInsteadDelete") + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("doNotDeleteFolder") + }); + addPanel(paneEl, "Conflict resolution", undefined, undefined, LEVEL_ADVANCED).then((paneEl) => { + + paneEl.addClass("wizardHidden"); + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("resolveConflictsByNewerFile") + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("checkConflictOnlyOnOpen") + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("showMergeDialogOnlyOnActive") + }); + + addPanel(paneEl, "Sync settings via markdown", undefined, undefined, LEVEL_ADVANCED).then((paneEl) => { + new Setting(paneEl) + .autoWireText("settingSyncFile", { holdValue: true }) + .addApplyButton(["settingSyncFile"]) + + new Setting(paneEl) + .autoWireToggle("writeCredentialsForSettingSync"); + + new Setting(paneEl) + .autoWireToggle("notifyAllSettingSyncFile") + }); + + addPanel(paneEl, "Hidden files", undefined, undefined, LEVEL_ADVANCED).then((paneEl) => { + paneEl.addClass("wizardHidden"); + + + const LABEL_ENABLED = "πŸ” : Enabled"; + const LABEL_DISABLED = "⏹️ : Disabled" + + const hiddenFileSyncSetting = new Setting(paneEl) + .setName("Hidden file synchronization").setClass("wizardHidden") + const hiddenFileSyncSettingEl = hiddenFileSyncSetting.settingEl + const hiddenFileSyncSettingDiv = hiddenFileSyncSettingEl.createDiv(""); + hiddenFileSyncSettingDiv.innerText = this.editingSettings.syncInternalFiles ? LABEL_ENABLED : LABEL_DISABLED; + + if (this.editingSettings.syncInternalFiles) { + new Setting(paneEl) + .setName("Disable Hidden files sync") + .setClass("wizardHidden") + .addButton((button) => { + button.setButtonText("Disable") + .onClick(async () => { + this.editingSettings.syncInternalFiles = false; + await this.saveAllDirtySettings(); + this.display(); + }) + }) + } else { + + new Setting(paneEl) + .setName("Enable Hidden files sync") + .setClass("wizardHidden") + .addButton((button) => { + button.setButtonText("Merge") + .onClick(async () => { + this.closeSetting() + // this.resetEditingSettings(); + await this.plugin.addOnSetup.configureHiddenFileSync("MERGE"); + }) + }) + .addButton((button) => { + button.setButtonText("Fetch") + .onClick(async () => { + this.closeSetting() + // this.resetEditingSettings(); + await this.plugin.addOnSetup.configureHiddenFileSync("FETCH"); + }) + }) + .addButton((button) => { + button.setButtonText("Overwrite") + .onClick(async () => { + this.closeSetting() + // this.resetEditingSettings(); + await this.plugin.addOnSetup.configureHiddenFileSync("OVERWRITE"); + }) + }); + } + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("syncInternalFilesBeforeReplication", + { onUpdate: visibleOnly(() => this.isConfiguredAs("watchInternalFileChanges", false)) } + ) + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("syncInternalFilesInterval", { clampMin: 10, acceptZero: true }) + + }); + + }); + addPane(containerEl, "Selector", "🚦", 33, false, LEVEL_ADVANCED).then((paneEl) => { + addPanel(paneEl, "Normal Files").then((paneEl) => { + paneEl.addClass("wizardHidden"); + + const syncFilesSetting = new Setting(paneEl) + .setName("Synchronising files") + .setDesc("(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.") + .setClass("wizardHidden") + new MultipleRegExpControl( + { + target: syncFilesSetting.controlEl, + props: { + patterns: this.editingSettings.syncOnlyRegEx.split("|[]|"), + originals: [...this.editingSettings.syncOnlyRegEx.split("|[]|")], + apply: async (newPatterns: string[]) => { + this.editingSettings.syncOnlyRegEx = newPatterns.map((e: string) => e.trim()).filter(e => e != "").join("|[]|"); + await this.saveAllDirtySettings(); + this.display(); + } + } + } + ) + + const nonSyncFilesSetting = new Setting(paneEl) + .setName("Non-Synchronising files") + .setDesc("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.") + .setClass("wizardHidden"); + + new MultipleRegExpControl( + { + target: nonSyncFilesSetting.controlEl, + props: { + patterns: this.editingSettings.syncIgnoreRegEx.split("|[]|"), + originals: [...this.editingSettings.syncIgnoreRegEx.split("|[]|")], + apply: async (newPatterns: string[]) => { + this.editingSettings.syncIgnoreRegEx = newPatterns.map(e => e.trim()).filter(e => e != "").join("|[]|"); + await this.saveAllDirtySettings(); + this.display(); + } + } + } + ) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("syncMaxSizeInMB", { clampMin: 0 }) + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("useIgnoreFiles") + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireTextArea("ignoreFiles", { onUpdate: visibleOnly(() => this.isConfiguredAs("useIgnoreFiles", true)) }); + }); + addPanel(paneEl, "Hidden Files", undefined, undefined, LEVEL_ADVANCED).then((paneEl) => { + + const defaultSkipPattern = "\\/node_modules\\/, \\/\\.git\\/, ^\\.git\\/, \\/obsidian-livesync\\/"; + const defaultSkipPatternXPlat = defaultSkipPattern + ",\\/workspace$ ,\\/workspace.json$,\\/workspace-mobile.json$"; + + const pat = this.editingSettings.syncInternalFilesIgnorePatterns.split(",").map(x => x.trim()).filter(x => x != ""); + const patSetting = new Setting(paneEl) + .setName("Ignore patterns") + .setClass("wizardHidden") + .setDesc(""); + + new MultipleRegExpControl( + { + target: patSetting.controlEl, + props: { + patterns: pat, originals: [...pat], apply: async (newPatterns: string[]) => { + this.editingSettings.syncInternalFilesIgnorePatterns = newPatterns.map(e => e.trim()).filter(e => e != "").join(", "); + await this.saveAllDirtySettings(); + this.display(); + } + } + } + ) + + const addDefaultPatterns = async (patterns: string) => { + const oldList = this.editingSettings.syncInternalFilesIgnorePatterns.split(",").map(x => x.trim()).filter(x => x != ""); + const newList = patterns.split(",").map(x => x.trim()).filter(x => x != ""); + const allSet = new Set([...oldList, ...newList]); + this.editingSettings.syncInternalFilesIgnorePatterns = [...allSet].join(", "); + await this.saveAllDirtySettings(); + this.display(); + } + + new Setting(paneEl) + .setName("Add default patterns") + .setClass("wizardHidden") + .addButton((button) => { + button.setButtonText("Default") + .onClick(async () => { + await addDefaultPatterns(defaultSkipPattern); + }) + }).addButton((button) => { + button.setButtonText("Cross-platform") + .onClick(async () => { + await addDefaultPatterns(defaultSkipPatternXPlat); + }) + }) + }); + }); + + addPane(containerEl, "Customization sync", "πŸ”Œ", 60, false, LEVEL_ADVANCED).then((paneEl) => { + // With great respect, thank you TfTHacker! + // Refer: https://github.com/TfTHacker/obsidian42-brat/blob/main/src/features/BetaPlugins.ts + addPanel(paneEl, "Customization Sync").then((paneEl) => { + const enableOnlyOnPluginSyncIsNotEnabled = enableOnly(() => this.isConfiguredAs("usePluginSync", false)); + const visibleOnlyOnPluginSyncEnabled = visibleOnly(() => this.isConfiguredAs("usePluginSync", true)); + + new Setting(paneEl) + .autoWireText("deviceAndVaultName", { + placeHolder: "desktop", + onUpdate: enableOnlyOnPluginSyncIsNotEnabled + }); + + new Setting(paneEl) + .autoWireToggle("usePluginSyncV2") + + new Setting(paneEl) + .autoWireToggle("usePluginSync", { + onUpdate: enableOnly(() => !this.isConfiguredAs("deviceAndVaultName", "")) + }); + + new Setting(paneEl) + .autoWireToggle("autoSweepPlugins", { + onUpdate: visibleOnlyOnPluginSyncEnabled + }) + + new Setting(paneEl) + .autoWireToggle("autoSweepPluginsPeriodic", { + onUpdate: visibleOnly(() => this.isConfiguredAs("usePluginSync", true) && this.isConfiguredAs("autoSweepPlugins", true)) + }) + new Setting(paneEl) + .autoWireToggle("notifyPluginOrSettingUpdated", { + onUpdate: visibleOnlyOnPluginSyncEnabled + }) + + new Setting(paneEl) + .setName("Open") + .setDesc("Open the dialog") + .addButton((button) => { + button + .setButtonText("Open") + .setDisabled(false) + .onClick(() => { + this.plugin.addOnConfigSync.showPluginSyncModal(); + }); + }) + .addOnUpdate(visibleOnlyOnPluginSyncEnabled); + }); + }); + + + + addPane(containerEl, "Hatch", "🧰", 50, false).then((paneEl) => { + // const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` }); + // hatchWarn.addClass("op-warn-info"); + addPanel(paneEl, "Reporting Issue").then((paneEl) => { + new Setting(paneEl) + .setName("Make report to inform the issue") + .addButton((button) => + button + .setButtonText("Make report") + .setCta() + .setDisabled(false) + .onClick(async () => { + let responseConfig: any = {}; + const REDACTED = "𝑅𝐸𝐷𝐴𝐢𝑇𝐸𝐷"; + if (this.editingSettings.remoteType == REMOTE_COUCHDB) { + try { + const r = await requestToCouchDB(this.editingSettings.couchDB_URI, this.editingSettings.couchDB_USER, this.editingSettings.couchDB_PASSWORD, window.origin); + + Logger(JSON.stringify(r.json, null, 2)); + + responseConfig = r.json; + responseConfig["couch_httpd_auth"].secret = REDACTED; + responseConfig["couch_httpd_auth"].authentication_db = REDACTED; + responseConfig["couch_httpd_auth"].authentication_redirect = REDACTED; + responseConfig["couchdb"].uuid = REDACTED; + responseConfig["admins"] = REDACTED; + + } catch (ex) { + responseConfig = "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour." + } + } else if (this.editingSettings.remoteType == REMOTE_MINIO) { + responseConfig = "Object Storage Synchronisation"; + // + } + const pluginConfig = JSON.parse(JSON.stringify(this.editingSettings)) as ObsidianLiveSyncSettings; + pluginConfig.couchDB_DBNAME = REDACTED; + pluginConfig.couchDB_PASSWORD = REDACTED; + const scheme = pluginConfig.couchDB_URI.startsWith("http:") ? "(HTTP)" : (pluginConfig.couchDB_URI.startsWith("https:")) ? "(HTTPS)" : "" + pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) ? "cloudant" : `self-hosted${scheme}`; + pluginConfig.couchDB_USER = REDACTED; + pluginConfig.passphrase = REDACTED; + pluginConfig.encryptedPassphrase = REDACTED; + pluginConfig.encryptedCouchDBConnection = REDACTED; + pluginConfig.accessKey = REDACTED; + pluginConfig.secretKey = REDACTED; + pluginConfig.region = `${REDACTED}(${pluginConfig.region.length} letters)`; + pluginConfig.bucket = `${REDACTED}(${pluginConfig.bucket.length} letters)`; + pluginConfig.pluginSyncExtendedSetting = {}; + const endpoint = pluginConfig.endpoint; + if (endpoint == "") { + pluginConfig.endpoint = "Not configured or AWS"; + } else { + const endpointScheme = pluginConfig.endpoint.startsWith("http:") ? "(HTTP)" : (pluginConfig.endpoint.startsWith("https:")) ? "(HTTPS)" : ""; + pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`; + } + const obsidianInfo = `Navigator: ${navigator.userAgent} FileSystem: ${this.plugin.vaultAccess.isStorageInsensitive() ? "insensitive" : "sensitive"}`; - const msgConfig = `---- Obsidian info ---- + const msgConfig = `---- Obsidian info ---- ${obsidianInfo} ---- remote config ---- ${stringifyYaml(responseConfig)} ---- Plug-in config --- version:${manifestVersion} ${stringifyYaml(pluginConfig)}`; - console.log(msgConfig); - await navigator.clipboard.writeText(msgConfig); - Logger(`Information has been copied to clipboard`, LOG_LEVEL_NOTICE); - }) - ); - - if (this.plugin?.replicator?.remoteLockedAndDeviceNotAccepted) { - const c = this.createEl(containerHatchEl, "div", { - text: "To prevent unwanted vault corruption, the remote database has been locked for synchronization, and this device was not marked as 'resolved'. It caused by some operations like this. Re-initialized. Local database initialization should be required. Please back your vault up, reset the local database, and press 'Mark this device as resolved'. ", + console.log(msgConfig); + await navigator.clipboard.writeText(msgConfig); + Logger(`Information has been copied to clipboard`, LOG_LEVEL_NOTICE); + }) + ); + new Setting(paneEl) + .autoWireToggle("writeLogToTheFile") }); - this.createEl(c, "button", { text: "I'm ready, mark this device 'resolved'" }, (e) => { - e.addClass("mod-warning"); - e.addEventListener("click", async () => { - await this.plugin.markRemoteResolved(); - c.remove(); - }); + + addPanel(paneEl, "Scram Switches").then((paneEl) => { + new Setting(paneEl) + .autoWireToggle("suspendFileWatching") + this.addOnSaved("suspendFileWatching", () => this.plugin.askReload()); + + new Setting(paneEl) + .autoWireToggle("suspendParseReplicationResult") + this.addOnSaved("suspendParseReplicationResult", () => this.plugin.askReload()); }); - c.addClass("op-warn"); - } else { - if (this.plugin?.replicator?.remoteLocked) { - const c = this.createEl(containerHatchEl, "div", { - text: "To prevent unwanted vault corruption, the remote database has been locked for synchronization. (This device is marked 'resolved') When all your devices are marked 'resolved', unlock the database.", - }); - this.createEl(c, "button", { text: "I'm ready, unlock the database" }, (e) => { - e.addClass("mod-warning"); - e.addEventListener("click", async () => { - await this.plugin.markRemoteUnlocked(); - c.remove(); - }); - }); - c.addClass("op-warn"); - } - } - new Setting(containerHatchEl) - .setName("Back to non-configured") - .addButton((button) => - button - .setButtonText("Back") - .setDisabled(false) - .onClick(async () => { - this.editingSettings.isConfigured = false; - await this.saveAllDirtySettings(); - this.plugin.askReload(); - })); - const hatchWarn = this.createEl(containerHatchEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` }); - hatchWarn.addClass("op-warn-info"); + addPanel(paneEl, "Recovery and Repair").then((paneEl) => { - - const addResult = (path: string, file: TFile | false, fileOnDB: LoadedEntry | false) => { - resultArea.appendChild(this.createEl(resultArea, "div", {}, el => { - el.appendChild(this.createEl(el, "h6", { text: path })); - el.appendChild(this.createEl(el, "div", {}, infoGroupEl => { - infoGroupEl.appendChild(this.createEl(infoGroupEl, "div", { text: `Storage : Modified: ${!file ? `Missing:` : `${new Date(file.stat.mtime).toLocaleString()}, Size:${file.stat.size}`}` })) - infoGroupEl.appendChild(this.createEl(infoGroupEl, "div", { text: `Database: Modified: ${!fileOnDB ? `Missing:` : `${new Date(fileOnDB.mtime).toLocaleString()}, Size:${fileOnDB.size}`}` })) - })); - if (fileOnDB && file) { - el.appendChild(this.createEl(el, "button", { text: "Show history" }, buttonEl => { - buttonEl.onClickEvent(() => { - this.plugin.showHistory(file, fileOnDB._id); - }) - })) - } - if (file) { - el.appendChild(this.createEl(el, "button", { text: "Storage -> Database" }, buttonEl => { - buttonEl.onClickEvent(() => { - this.plugin.updateIntoDB(file, undefined, true); - el.remove(); - }) - })) - } - if (fileOnDB) { - el.appendChild(this.createEl(el, "button", { text: "Database -> Storage" }, buttonEl => { - buttonEl.onClickEvent(() => { - this.plugin.pullFile(this.plugin.getPath(fileOnDB), undefined, true, undefined, false); - el.remove(); - }) - })) - } - return el; - })) - } - - const checkBetweenStorageAndDatabase = async (file: TFile, fileOnDB: LoadedEntry) => { - const dataContent = readAsBlob(fileOnDB); - const content = createBlob(await this.plugin.vaultAccess.vaultReadAuto(file)) - if (await isDocContentSame(content, dataContent)) { - Logger(`Compare: SAME: ${file.path}`) - } else { - Logger(`Compare: CONTENT IS NOT MATCHED! ${file.path}`, LOG_LEVEL_NOTICE); - addResult(file.path, file, fileOnDB) - } - } - new Setting(containerHatchEl) - .setName("Recreate missing chunks for all files") - .setDesc("This will recreate chunks for all files. If there were missing chunks, this may fix the errors.") - .addButton((button) => - button. - setButtonText("Recreate all") - .setCta() - .onClick(async () => { - await this.plugin.createAllChunks(true); - }) - ) - - new Setting(containerHatchEl) - .setName("Verify and repair all files") - .setDesc("Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.") - .addButton((button) => - button - .setButtonText("Verify all") - .setDisabled(false) - .setCta() - .onClick(async () => { - Logger("Start verifying all files", LOG_LEVEL_NOTICE, "verify"); - const files = this.app.vault.getFiles(); - const documents = [] as FilePathWithPrefix[]; - - const adn = this.plugin.localDatabase.findAllNormalDocs() - for await (const i of adn) documents.push(this.plugin.getPath(i)); - const allPaths = [...new Set([...documents, ...files.map(e => e.path as FilePathWithPrefix)])]; - let i = 0; - const incProc = () => { - i++; - if (i % 25 == 0) Logger(`Checking ${i}/${files.length} files \n`, LOG_LEVEL_NOTICE, "verify-processed"); + const addResult = (path: string, file: TFile | false, fileOnDB: LoadedEntry | false) => { + resultArea.appendChild(this.createEl(resultArea, "div", {}, el => { + el.appendChild(this.createEl(el, "h6", { text: path })); + el.appendChild(this.createEl(el, "div", {}, infoGroupEl => { + infoGroupEl.appendChild(this.createEl(infoGroupEl, "div", { text: `Storage : Modified: ${!file ? `Missing:` : `${new Date(file.stat.mtime).toLocaleString()}, Size:${file.stat.size}`}` })) + infoGroupEl.appendChild(this.createEl(infoGroupEl, "div", { text: `Database: Modified: ${!fileOnDB ? `Missing:` : `${new Date(fileOnDB.mtime).toLocaleString()}, Size:${fileOnDB.size}`}` })) + })); + if (fileOnDB && file) { + el.appendChild(this.createEl(el, "button", { text: "Show history" }, buttonEl => { + buttonEl.onClickEvent(() => { + this.plugin.showHistory(file, fileOnDB._id); + }) + })) } - const semaphore = Semaphore(10); - const processes = allPaths.map(async path => { - try { - if (shouldBeIgnored(path)) { - return incProc(); - } - const abstractFile = this.plugin.vaultAccess.getAbstractFileByPath(path); - const fileOnStorage = abstractFile instanceof TFile ? abstractFile : false; - if (!await this.plugin.isTargetFile(path)) return incProc(); - const releaser = await semaphore.acquire(1) - if (fileOnStorage && this.plugin.isFileSizeExceeded(fileOnStorage.stat.size)) return incProc(); - try { - const fileOnDB = await this.plugin.localDatabase.getDBEntry(path); - if (fileOnDB && this.plugin.isFileSizeExceeded(fileOnDB.size)) return incProc(); + if (file) { + el.appendChild(this.createEl(el, "button", { text: "Storage -> Database" }, buttonEl => { + buttonEl.onClickEvent(() => { + this.plugin.updateIntoDB(file, undefined, true); + el.remove(); + }) + })) + } + if (fileOnDB) { + el.appendChild(this.createEl(el, "button", { text: "Database -> Storage" }, buttonEl => { + buttonEl.onClickEvent(() => { + this.plugin.pullFile(this.plugin.getPath(fileOnDB), undefined, true, undefined, false); + el.remove(); + }) + })) + } + return el; + })) + } - if (!fileOnDB && fileOnStorage) { - Logger(`Compare: Not found on the local database: ${path}`, LOG_LEVEL_NOTICE); - addResult(path, fileOnStorage, false) - return incProc(); - } - if (fileOnDB && !fileOnStorage) { - Logger(`Compare: Not found on the storage: ${path}`, LOG_LEVEL_NOTICE); - addResult(path, false, fileOnDB) - return incProc(); - } - if (fileOnStorage && fileOnDB) { - await checkBetweenStorageAndDatabase(fileOnStorage, fileOnDB) - } - } catch (ex) { - Logger(`Error while processing ${path}`, LOG_LEVEL_NOTICE); - Logger(ex, LOG_LEVEL_VERBOSE); - } finally { - releaser(); - incProc(); + const checkBetweenStorageAndDatabase = async (file: TFile, fileOnDB: LoadedEntry) => { + const dataContent = readAsBlob(fileOnDB); + const content = createBlob(await this.plugin.vaultAccess.vaultReadAuto(file)) + if (await isDocContentSame(content, dataContent)) { + Logger(`Compare: SAME: ${file.path}`) + } else { + Logger(`Compare: CONTENT IS NOT MATCHED! ${file.path}`, LOG_LEVEL_NOTICE); + addResult(file.path, file, fileOnDB) + } + } + new Setting(paneEl) + .setName("Recreate missing chunks for all files") + .setDesc("This will recreate chunks for all files. If there were missing chunks, this may fix the errors.") + .addButton((button) => + button. + setButtonText("Recreate all") + .setCta() + .onClick(async () => { + await this.plugin.createAllChunks(true); + }) + ) + + new Setting(paneEl) + .setName("Verify and repair all files") + .setDesc("Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.") + .addButton((button) => + button + .setButtonText("Verify all") + .setDisabled(false) + .setCta() + .onClick(async () => { + this.plugin.localDatabase.hashCaches.clear(); + Logger("Start verifying all files", LOG_LEVEL_NOTICE, "verify"); + const files = this.app.vault.getFiles(); + const documents = [] as FilePathWithPrefix[]; + + const adn = this.plugin.localDatabase.findAllNormalDocs() + for await (const i of adn) documents.push(this.plugin.getPath(i)); + const allPaths = [...new Set([...documents, ...files.map(e => e.path as FilePathWithPrefix)])]; + let i = 0; + const incProc = () => { + i++; + if (i % 25 == 0) Logger(`Checking ${i}/${files.length} files \n`, LOG_LEVEL_NOTICE, "verify-processed"); } - } catch (ex) { - Logger(`Error while processing without semaphore ${path}`, LOG_LEVEL_NOTICE); - Logger(ex, LOG_LEVEL_VERBOSE); - } - }); - await Promise.all(processes); - Logger("done", LOG_LEVEL_NOTICE, "verify"); - // Logger(`${i}/${files.length}\n`, LOG_LEVEL_NOTICE, "verify-processed"); - }) - ); - const resultArea = containerHatchEl.createDiv({ text: "" }); - new Setting(containerHatchEl) - .setName("Check and convert non-path-obfuscated files") - .setDesc("") - .addButton((button) => - button - .setButtonText("Perform") - .setDisabled(false) - .setWarning() - .onClick(async () => { - for await (const docName of this.plugin.localDatabase.findAllDocNames()) { - if (!docName.startsWith("f:")) { - const idEncoded = await this.plugin.path2id(docName as FilePathWithPrefix); - const doc = await this.plugin.localDatabase.getRaw(docName as DocumentID); - if (!doc) continue; - if (doc.type != "newnote" && doc.type != "plain") { - continue; - } - if (doc?.deleted ?? false) continue; - const newDoc = { ...doc }; - //Prepare converted data - newDoc._id = idEncoded; - newDoc.path = docName as FilePathWithPrefix; - // @ts-ignore - delete newDoc._rev; - try { - const obfuscatedDoc = await this.plugin.localDatabase.getRaw(idEncoded, { revs_info: true }); - // Unfortunately we have to delete one of them. - // Just now, save it as a conflicted document. - obfuscatedDoc._revs_info?.shift(); // Drop latest revision. - const previousRev = obfuscatedDoc._revs_info?.shift(); // Use second revision. - if (previousRev) { - newDoc._rev = previousRev.rev; - } else { - //If there are no revisions, set the possibly unique one - newDoc._rev = "1-" + (`00000000000000000000000000000000${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}`.slice(-32)); - } - const ret = await this.plugin.localDatabase.putRaw(newDoc, { force: true }); - if (ret.ok) { - Logger(`${docName} has been converted as conflicted document`, LOG_LEVEL_NOTICE); - doc._deleted = true; - if ((await this.plugin.localDatabase.putRaw(doc)).ok) { - Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE); + const semaphore = Semaphore(10); + const processes = allPaths.map(async path => { + try { + if (shouldBeIgnored(path)) { + return incProc(); } - await this.plugin.queueConflictCheck(docName as FilePathWithPrefix); - } else { - Logger(`Converting ${docName} Failed!`, LOG_LEVEL_NOTICE); - Logger(ret, LOG_LEVEL_VERBOSE); + const abstractFile = this.plugin.vaultAccess.getAbstractFileByPath(path); + const fileOnStorage = abstractFile instanceof TFile ? abstractFile : false; + if (!await this.plugin.isTargetFile(path)) return incProc(); + const releaser = await semaphore.acquire(1) + if (fileOnStorage && this.plugin.isFileSizeExceeded(fileOnStorage.stat.size)) return incProc(); + try { + const fileOnDB = await this.plugin.localDatabase.getDBEntry(path); + if (fileOnDB && this.plugin.isFileSizeExceeded(fileOnDB.size)) return incProc(); + + if (!fileOnDB && fileOnStorage) { + Logger(`Compare: Not found on the local database: ${path}`, LOG_LEVEL_NOTICE); + addResult(path, fileOnStorage, false) + return incProc(); + } + if (fileOnDB && !fileOnStorage) { + Logger(`Compare: Not found on the storage: ${path}`, LOG_LEVEL_NOTICE); + addResult(path, false, fileOnDB) + return incProc(); + } + if (fileOnStorage && fileOnDB) { + await checkBetweenStorageAndDatabase(fileOnStorage, fileOnDB) + } + } catch (ex) { + Logger(`Error while processing ${path}`, LOG_LEVEL_NOTICE); + Logger(ex, LOG_LEVEL_VERBOSE); + } finally { + releaser(); + incProc(); + } + } catch (ex) { + Logger(`Error while processing without semaphore ${path}`, LOG_LEVEL_NOTICE); + Logger(ex, LOG_LEVEL_VERBOSE); } - } catch (ex: any) { - if (ex?.status == 404) { - // We can perform this safely - if ((await this.plugin.localDatabase.putRaw(newDoc)).ok) { - Logger(`${docName} has been converted`, LOG_LEVEL_NOTICE); - doc._deleted = true; - if ((await this.plugin.localDatabase.putRaw(doc)).ok) { - Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE); + }); + await Promise.all(processes); + Logger("done", LOG_LEVEL_NOTICE, "verify"); + // Logger(`${i}/${files.length}\n`, LOG_LEVEL_NOTICE, "verify-processed"); + }) + ); + const resultArea = paneEl.createDiv({ text: "" }); + new Setting(paneEl) + .setName("Check and convert non-path-obfuscated files") + .setDesc("") + .addButton((button) => + button + .setButtonText("Perform") + .setDisabled(false) + .setWarning() + .onClick(async () => { + for await (const docName of this.plugin.localDatabase.findAllDocNames()) { + if (!docName.startsWith("f:")) { + const idEncoded = await this.plugin.path2id(docName as FilePathWithPrefix); + const doc = await this.plugin.localDatabase.getRaw(docName as DocumentID); + if (!doc) continue; + if (doc.type != "newnote" && doc.type != "plain") { + continue; + } + if (doc?.deleted ?? false) continue; + const newDoc = { ...doc }; + //Prepare converted data + newDoc._id = idEncoded; + newDoc.path = docName as FilePathWithPrefix; + // @ts-ignore + delete newDoc._rev; + try { + const obfuscatedDoc = await this.plugin.localDatabase.getRaw(idEncoded, { revs_info: true }); + // Unfortunately we have to delete one of them. + // Just now, save it as a conflicted document. + obfuscatedDoc._revs_info?.shift(); // Drop latest revision. + const previousRev = obfuscatedDoc._revs_info?.shift(); // Use second revision. + if (previousRev) { + newDoc._rev = previousRev.rev; + } else { + //If there are no revisions, set the possibly unique one + newDoc._rev = "1-" + (`00000000000000000000000000000000${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}`.slice(-32)); + } + const ret = await this.plugin.localDatabase.putRaw(newDoc, { force: true }); + if (ret.ok) { + Logger(`${docName} has been converted as conflicted document`, LOG_LEVEL_NOTICE); + doc._deleted = true; + if ((await this.plugin.localDatabase.putRaw(doc)).ok) { + Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE); + } + await this.plugin.queueConflictCheck(docName as FilePathWithPrefix); + } else { + Logger(`Converting ${docName} Failed!`, LOG_LEVEL_NOTICE); + Logger(ret, LOG_LEVEL_VERBOSE); + } + } catch (ex: any) { + if (ex?.status == 404) { + // We can perform this safely + if ((await this.plugin.localDatabase.putRaw(newDoc)).ok) { + Logger(`${docName} has been converted`, LOG_LEVEL_NOTICE); + doc._deleted = true; + if ((await this.plugin.localDatabase.putRaw(doc)).ok) { + Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE); + } + } + } else { + Logger(`Something went wrong while converting ${docName}`, LOG_LEVEL_NOTICE); + Logger(ex, LOG_LEVEL_VERBOSE); + // Something wrong. } } - } else { - Logger(`Something went wrong while converting ${docName}`, LOG_LEVEL_NOTICE); - Logger(ex, LOG_LEVEL_VERBOSE); - // Something wrong. } } - } - } - Logger(`Converting finished`, LOG_LEVEL_NOTICE); - })); + Logger(`Converting finished`, LOG_LEVEL_NOTICE); + })); + }); + addPanel(paneEl, "Reset").then((paneEl) => { + new Setting(paneEl) + .setName("Back to non-configured") + .addButton((button) => + button + .setButtonText("Back") + .setDisabled(false) + .onClick(async () => { + this.editingSettings.isConfigured = false; + await this.saveAllDirtySettings(); + this.plugin.askReload(); + })); - new Setting(containerHatchEl) - .setName("Delete all customization sync data") - .addButton((button) => - button - .setButtonText("Delete") - .setDisabled(false) - .setWarning() - .onClick(async () => { - Logger(`Deleting customization sync data`, LOG_LEVEL_NOTICE); - const entriesToDelete = (await this.plugin.localDatabase.allDocsRaw({ - startkey: "ix:", - endkey: "ix:\u{10ffff}", - include_docs: true - })); - const newData = entriesToDelete.rows.map(e => ({ ...e.doc, _deleted: true })); - const r = await this.plugin.localDatabase.bulkDocsRaw(newData as any[]); - // Do not care about the result. - Logger(`${r.length} items have been removed, to confirm how many items are left, please perform it again.`, LOG_LEVEL_NOTICE); - })) + new Setting(paneEl) + .setName("Delete all customization sync data") + .addButton((button) => + button + .setButtonText("Delete") + .setDisabled(false) + .setWarning() + .onClick(async () => { + Logger(`Deleting customization sync data`, LOG_LEVEL_NOTICE); + const entriesToDelete = (await this.plugin.localDatabase.allDocsRaw({ + startkey: "ix:", + endkey: "ix:\u{10ffff}", + include_docs: true + })); + const newData = entriesToDelete.rows.map(e => ({ ...e.doc, _deleted: true })); + const r = await this.plugin.localDatabase.bulkDocsRaw(newData as any[]); + // Do not care about the result. + Logger(`${r.length} items have been removed, to confirm how many items are left, please perform it again.`, LOG_LEVEL_NOTICE); + })) + }); + }); + addPane(containerEl, "Advanced", "πŸ”§", 46, false, LEVEL_ADVANCED).then((paneEl) => { + addPanel(paneEl, "Memory cache").then((paneEl) => { + new Setting(paneEl) + .autoWireNumeric("hashCacheMaxCount", { clampMin: 10 }); + new Setting(paneEl) + .autoWireNumeric("hashCacheMaxAmount", { clampMin: 1 }); + }); + addPanel(paneEl, "Local Database Tweak").then((paneEl) => { + paneEl.addClass("wizardHidden"); - new Setting(containerHatchEl) - .autoWireToggle("suspendFileWatching") - this.addOnSaved("suspendFileWatching", () => this.plugin.askReload()); + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("customChunkSize", { clampMin: 0 }) - new Setting(containerHatchEl) - .autoWireToggle("suspendParseReplicationResult") - this.addOnSaved("suspendParseReplicationResult", () => this.plugin.askReload()); + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("enableChunkSplitterV2", { onUpdate: enableOnly(() => this.isConfiguredAs("useSegmenter", false)) }) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("useSegmenter", { onUpdate: enableOnly(() => this.isConfiguredAs("enableChunkSplitterV2", false)) }) + }); - new Setting(containerHatchEl) - .autoWireToggle("writeLogToTheFile") + addPanel(paneEl, "Transfer Tweak").then((paneEl) => { + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("readChunksOnline", { onUpdate: onlyOnCouchDB }) - this.createEl(containerHatchEl, "h4", { - text: sanitizeHTMLToDom(`Compatibility`), - cls: "wizardHidden" + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("concurrencyOfReadChunksOnline", { clampMin: 10, onUpdate: onlyOnCouchDB }) + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("minimumIntervalOfReadChunksOnline", { clampMin: 10, onUpdate: onlyOnCouchDB }) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB }) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("sendChunksBulkMaxSize", { clampMax: 100, clampMin: 1, onUpdate: onlyOnCouchDB }) + + }); }); - new Setting(containerHatchEl) - .setClass("wizardHidden") - .autoWireToggle("deleteMetadataOfDeletedFiles") - - new Setting(containerHatchEl) - .setClass("wizardHidden") - .autoWireNumeric("automaticallyDeleteMetadataOfDeletedFiles", { onUpdate: visibleOnly(() => this.isConfiguredAs("deleteMetadataOfDeletedFiles", true)) }) + addPane(containerEl, "Power users", "πŸ’ͺ", 47, true, LEVEL_POWER_USER).then((paneEl) => { - new Setting(containerHatchEl) - .autoWireToggle("useIndexedDBAdapter", { invert: true }) - new Setting(containerHatchEl) - .autoWireToggle("doNotUseFixedRevisionForChunks", { holdValue: true }) - .setClass("wizardHidden") - new Setting(containerHatchEl) - .autoWireToggle("handleFilenameCaseSensitive", { holdValue: true }) - .setClass("wizardHidden") + addPanel(paneEl, "Remote Database Tweak").then((paneEl) => { - new Setting(containerHatchEl) - .setName("Apply") - .setDesc("These configurations require a database rebuild.") - .setClass("wizardHidden") - .addButton((button) => - button - .setButtonText("Apply and rebuild") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await this.saveAllDirtySettings(); - // await this.applySetting(["doNotUseFixedRevisionForChunks", "handleFilenameCaseSensitive"]); - // await this.saveSettings(["doNotUseFixedRevisionForChunks", "handleFilenameCaseSensitive"]); - // debugger; - await rebuildDB("rebuildBothByThisDevice"); + new Setting(paneEl).autoWireToggle("useEden").setClass("wizardHidden"); + const onlyUsingEden = visibleOnly(() => this.isConfiguredAs("useEden", true)); + new Setting(paneEl).autoWireNumeric("maxChunksInEden", { onUpdate: onlyUsingEden }).setClass("wizardHidden"); + new Setting(paneEl).autoWireNumeric("maxTotalLengthInEden", { onUpdate: onlyUsingEden }).setClass("wizardHidden"); + new Setting(paneEl).autoWireNumeric("maxAgeInEden", { onUpdate: onlyUsingEden }).setClass("wizardHidden"); + + new Setting(paneEl).autoWireToggle("enableCompression").setClass("wizardHidden"); + }); + + + + addPanel(paneEl, "CouchDB Connection Tweak", undefined, onlyOnCouchDB).then((paneEl) => { + paneEl.addClass("wizardHidden"); + + this.createEl(paneEl, "div", { + text: `If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.`, + }, undefined, onlyOnCouchDB).addClass("wizardHidden"); + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("batch_size", { clampMin: 2, onUpdate: onlyOnCouchDB }) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("batches_limit", { clampMin: 2, onUpdate: onlyOnCouchDB }) + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("useTimeouts", { onUpdate: onlyOnCouchDB }); + }); + addPanel(paneEl, "Configuration Encryption").then((paneEl) => { + const passphrase_options: Record = { + "": "Default", + LOCALSTORAGE: "Use a custom passphrase", + ASK_AT_LAUNCH: "Ask an passphrase at every launch", + } + + new Setting(paneEl) + .setName("Encrypting sensitive configuration items") + .autoWireDropDown("configPassphraseStore", { options: passphrase_options, holdValue: true }) + .setClass("wizardHidden"); + + new Setting(paneEl) + .autoWireText("configPassphrase", { isPassword: true, holdValue: true }) + .setClass("wizardHidden") + .addOnUpdate(() => ({ + disabled: !this.isConfiguredAs("configPassphraseStore", "LOCALSTORAGE") + })) + new Setting(paneEl) + .addApplyButton(["configPassphrase", "configPassphraseStore"]) + .setClass("wizardHidden") + }); + }); + + addPane(containerEl, "Patches", "🩹", 51, false, LEVEL_EDGE_CASE).then((paneEl) => { + + + addPanel(paneEl, "Compatibility (Metadata)").then((paneEl) => { + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("deleteMetadataOfDeletedFiles") + + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireNumeric("automaticallyDeleteMetadataOfDeletedFiles", { onUpdate: visibleOnly(() => this.isConfiguredAs("deleteMetadataOfDeletedFiles", true)) }) + + }); + + + addPanel(paneEl, "Compatibility (Conflict Behaviour)").then((paneEl) => { + paneEl.addClass("wizardHidden"); + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("disableMarkdownAutoMerge") + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("writeDocumentsIfConflicted") + }); + + addPanel(paneEl, "Compatibility (Database structure)").then((paneEl) => { + + new Setting(paneEl) + .autoWireToggle("useIndexedDBAdapter", { invert: true, holdValue: true }) + + new Setting(paneEl) + .autoWireToggle("doNotUseFixedRevisionForChunks", { holdValue: true }) + .setClass("wizardHidden") + new Setting(paneEl) + .autoWireToggle("handleFilenameCaseSensitive", { holdValue: true }) + .setClass("wizardHidden") + + this.addOnSaved("useIndexedDBAdapter", async () => { + await this.saveAllDirtySettings(); + await rebuildDB("localOnly"); + }) + }); + + addPanel(paneEl, "Compatibility (Internal API Usage)").then((paneEl) => { + new Setting(paneEl) + .autoWireToggle("watchInternalFileChanges", { invert: true }) + + }); + + + + + addPanel(paneEl, "Edge case addressing (Database)").then((paneEl) => { + new Setting(paneEl) + .autoWireText("additionalSuffixOfDatabaseName", { holdValue: true }) + .addApplyButton(["additionalSuffixOfDatabaseName"]); + + this.addOnSaved("additionalSuffixOfDatabaseName", async (key) => { + Logger("Suffix has been changed. Reopening database...", LOG_LEVEL_NOTICE); + await this.plugin.initializeDatabase(); + }) + + new Setting(paneEl) + .autoWireDropDown("hashAlg", { + options: { + "": "Old Algorithm", + "xxhash32": "xxhash32 (Fast)", + "xxhash64": "xxhash64 (Fastest)", + "sha1": "Fallback (Without WebAssembly)" + } as Record }) - ) - .addOnUpdate(() => ({ - isCta: this.isSomeDirty(["doNotUseFixedRevisionForChunks", "handleFilenameCaseSensitive"]), - disabled: !this.isSomeDirty(["doNotUseFixedRevisionForChunks", "handleFilenameCaseSensitive"]), - })) - this.addOnSaved("useIndexedDBAdapter", async () => { - await this.saveAllDirtySettings(); - await rebuildDB("localOnly"); - }) - - new Setting(containerHatchEl) - .autoWireToggle("watchInternalFileChanges", { invert: true }) - - new Setting(containerHatchEl) - .autoWireText("additionalSuffixOfDatabaseName", { holdValue: true }) - .addApplyButton(["additionalSuffixOfDatabaseName"]); - - this.addOnSaved("additionalSuffixOfDatabaseName", async (key) => { - Logger("Suffix has been changed. Reopening database...", LOG_LEVEL_NOTICE); - await this.plugin.initializeDatabase(); - }) - - new Setting(containerHatchEl) - .autoWireDropDown("hashAlg", { - options: { - "": "Old Algorithm", - "xxhash32": "xxhash32 (Fast)", - "xxhash64": "xxhash64 (Fastest)", - "sha1": "Fallback (Without WebAssembly)" - } as Record - }) - this.addOnSaved("hashAlg", async () => { - await this.plugin.localDatabase.prepareHashFunctions(); - }) - - - new Setting(containerHatchEl) - .autoWireToggle("doNotSuspendOnFetching") - new Setting(containerHatchEl) - .autoWireToggle("disableCheckingConfigMismatch") - - new Setting(containerHatchEl) - .autoWireToggle("disableWorkerForGeneratingChunks") - - new Setting(containerHatchEl) - .autoWireToggle("processSmallFilesInUIThread", { - onUpdate: visibleOnly(() => this.isConfiguredAs("disableWorkerForGeneratingChunks", false)) - }) - - addScreenElement("50", containerHatchEl); - - - // With great respect, thank you TfTHacker! - // Refer: https://github.com/TfTHacker/obsidian42-brat/blob/main/src/features/BetaPlugins.ts - const containerPluginSettings = containerEl.createDiv(); - this.createEl(containerPluginSettings, "h3", { text: "Customization sync (beta 3)" }); - - const enableOnlyOnPluginSyncIsNotEnabled = enableOnly(() => this.isConfiguredAs("usePluginSync", false)); - const visibleOnlyOnPluginSyncEnabled = visibleOnly(() => this.isConfiguredAs("usePluginSync", true)); - - new Setting(containerPluginSettings) - .autoWireText("deviceAndVaultName", { - placeHolder: "desktop", - onUpdate: enableOnlyOnPluginSyncIsNotEnabled + this.addOnSaved("hashAlg", async () => { + await this.plugin.localDatabase.prepareHashFunctions(); + }) + }); + addPanel(paneEl, "Edge case addressing (Behaviour)").then((paneEl) => { + new Setting(paneEl) + .autoWireToggle("doNotSuspendOnFetching") + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("doNotDeleteFolder") }); - new Setting(containerPluginSettings) - .autoWireToggle("usePluginSyncV2") + addPanel(paneEl, "Edge case addressing (Processing)").then((paneEl) => { + new Setting(paneEl) + .autoWireToggle("disableWorkerForGeneratingChunks") - new Setting(containerPluginSettings) - .autoWireToggle("usePluginSync", { - onUpdate: enableOnly(() => !this.isConfiguredAs("deviceAndVaultName", "")) + new Setting(paneEl) + .autoWireToggle("processSmallFilesInUIThread", { + onUpdate: visibleOnly(() => this.isConfiguredAs("disableWorkerForGeneratingChunks", false)) + }) }); - new Setting(containerPluginSettings) - .autoWireToggle("autoSweepPlugins", { - onUpdate: visibleOnlyOnPluginSyncEnabled - }) + addPanel(paneEl, "Compatibility (Trouble addressed)").then((paneEl) => { + new Setting(paneEl) + .autoWireToggle("disableCheckingConfigMismatch") + }); + }); - new Setting(containerPluginSettings) - .autoWireToggle("autoSweepPluginsPeriodic", { - onUpdate: visibleOnly(() => this.isConfiguredAs("usePluginSync", true) && this.isConfiguredAs("autoSweepPlugins", true)) - }) - new Setting(containerPluginSettings) - .autoWireToggle("notifyPluginOrSettingUpdated", { - onUpdate: visibleOnlyOnPluginSyncEnabled - }) - new Setting(containerPluginSettings) - .setName("Open") - .setDesc("Open the dialog") - .addButton((button) => { - button - .setButtonText("Open") - .setDisabled(false) - .onClick(() => { - this.plugin.addOnConfigSync.showPluginSyncModal(); + + addPane(containerEl, "Maintenance", "πŸŽ›οΈ", 70, true).then((paneEl) => { + + const isRemoteLockedAndDeviceNotAccepted = () => this.plugin?.replicator?.remoteLockedAndDeviceNotAccepted; + const isRemoteLocked = () => this.plugin?.replicator?.remoteLocked; + // if (this.plugin?.replicator?.remoteLockedAndDeviceNotAccepted) { + this.createEl(paneEl, "div", { + text: "To prevent unwanted vault corruption, the remote database has been locked for synchronization, and this device was not marked as 'resolved'. It caused by some operations like this. Re-initialized. Local database initialization should be required. Please back your vault up, reset the local database, and press 'Mark this device as resolved'. ", + cls: "op-warn" + }, c => { + this.createEl(c, "button", { + text: "I'm ready, mark this device 'resolved'", + cls: "mod-warning" + }, (e) => { + e.addEventListener("click", async () => { + await this.plugin.markRemoteResolved(); + this.display(); }); - }) - .addOnUpdate(visibleOnlyOnPluginSyncEnabled); + }) + }, visibleOnly(isRemoteLockedAndDeviceNotAccepted)); + this.createEl(paneEl, "div", { + text: "To prevent unwanted vault corruption, the remote database has been locked for synchronization. (This device is marked 'resolved') When all your devices are marked 'resolved', unlock the database.", + cls: "op-warn" + }, c => + this.createEl(c, "button", { text: "I'm ready, unlock the database", cls: "mod-warning" }, (e) => { + e.addEventListener("click", async () => { + await this.plugin.markRemoteUnlocked(); + this.display(); + }); + }), visibleOnly(isRemoteLocked)); - addScreenElement("60", containerPluginSettings); + addPanel(paneEl, "Scram!").then((paneEl) => { + new Setting(paneEl) + .setName("Lock remote") + .setDesc("Lock remote to prevent synchronization with other devices.") + .addButton((button) => + button + .setButtonText("Lock") + .setDisabled(false) + .setWarning() + .onClick(async () => { + await this.plugin.markRemoteLocked(); + }) + ); - const containerMaintenanceEl = containerEl.createDiv(); + new Setting(paneEl) + .setName("Emergency restart") + .setDesc("place the flag file to prevent all operation and restart.") + .addButton((button) => + button + .setButtonText("Flag and restart") + .setDisabled(false) + .setWarning() + .onClick(async () => { + await this.plugin.vaultAccess.vaultCreate(FLAGMD_REDFLAG, ""); + this.plugin.performAppReload(); + }) + ); - this.createEl(containerMaintenanceEl, "h3", { text: "Maintenance" }); + }); - this.createEl(containerMaintenanceEl, "h4", { text: "Remote" }); + addPanel(paneEl, "Data-complementary Operations").then((paneEl) => { + new Setting(paneEl) + .setName("Resend") + .setDesc("Resend all chunks to the remote.") + .addButton((button) => + button + .setButtonText("Send chunks") + .setWarning() + .setDisabled(false) + .onClick(async () => { + if (this.plugin.replicator instanceof LiveSyncCouchDBReplicator) { + await this.plugin.replicator.sendChunks(this.plugin.settings, undefined, true, 0); + } + })) + .addOnUpdate(onlyOnCouchDB); + new Setting(paneEl) + .setName("Reset journal received history") + .setDesc("Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.") + .addButton((button) => + button + .setButtonText("Reset received") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await this.plugin.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({ + ...info, + receivedFiles: new Set(), + knownIDs: new Set() + })); + Logger(`Journal received history has been cleared.`, LOG_LEVEL_NOTICE); + }) + ).addOnUpdate(onlyOnMinIO); - new Setting(containerMaintenanceEl) - .setName("Perform compaction") - .setDesc("Compaction discards all of Eden in the non-latest revisions, reducing the storage usage. However, this operation requires the same free space on the remote as the current database.") - .addButton((button) => - button - .setButtonText("Perform") - .setDisabled(false) - .onClick(async () => { - const replicator = this.plugin.replicator as LiveSyncCouchDBReplicator; - Logger(`Compaction has been began`, LOG_LEVEL_NOTICE, "compaction") - if (await replicator.compactRemote(this.editingSettings)) { - Logger(`Compaction has been completed!`, LOG_LEVEL_NOTICE, "compaction"); - } else { - Logger(`Compaction has been failed!`, LOG_LEVEL_NOTICE, "compaction"); - } - }) - ).addOnUpdate(onlyOnCouchDB); + new Setting(paneEl) + .setName("Reset journal sent history") + .setDesc("Initialise journal sent history. On the next sync, every item except this device received will be sent again.") + .addButton((button) => + button + .setButtonText("Reset sent history") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await this.plugin.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({ + ...info, + lastLocalSeq: 0, + sentIDs: new Set(), + sentFiles: new Set() + })); + Logger(`Journal sent history has been cleared.`, LOG_LEVEL_NOTICE); + }) + ).addOnUpdate(onlyOnMinIO); - new Setting(containerMaintenanceEl) - .setName("Lock remote") - .setDesc("Lock remote to prevent synchronization with other devices.") - .addButton((button) => - button - .setButtonText("Lock") - .setDisabled(false) - .setWarning() - .onClick(async () => { - await this.plugin.markRemoteLocked(); - }) - ); + }); - new Setting(containerMaintenanceEl) - .setName("Overwrite remote") - .setDesc("Overwrite remote with local DB and passphrase.") - .addButton((button) => - button - .setButtonText("Send") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await rebuildDB("remoteOnly"); - }) - ) - .addButton((button) => - button - .setButtonText("Send chunks") - .setWarning() - .setDisabled(false) - .onClick(async () => { - if (this.plugin.replicator instanceof LiveSyncCouchDBReplicator) { - await this.plugin.replicator.sendChunks(this.plugin.settings, undefined, true, 0); - } - }) - ) - new Setting(containerMaintenanceEl) - .setName("Reset journal received history") - .setDesc("Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.") - .addButton((button) => - button - .setButtonText("Reset received") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await this.plugin.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({ - ...info, - receivedFiles: new Set(), - knownIDs: new Set() - })); - Logger(`Journal received history has been cleared.`, LOG_LEVEL_NOTICE); - }) - ).addOnUpdate(onlyOnMinIO); + addPanel(paneEl, "Rebuilding Operations (Local)").then((paneEl) => { - new Setting(containerMaintenanceEl) - .setName("Reset journal sent history") - .setDesc("Initialise journal sent history. On the next sync, every item except this device received will be sent again.") - .addButton((button) => - button - .setButtonText("Reset sent history") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await this.plugin.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({ - ...info, - lastLocalSeq: 0, - sentIDs: new Set(), - sentFiles: new Set() - })); - Logger(`Journal sent history has been cleared.`, LOG_LEVEL_NOTICE); - }) - ).addOnUpdate(onlyOnMinIO); + new Setting(paneEl) + .setName("Fetch from remote") + .setDesc("Restore or reconstruct local database from remote.") + .addButton((button) => + button + .setButtonText("Fetch") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await this.plugin.vaultAccess.vaultCreate(FLAGMD_REDFLAG3_HR, ""); + this.plugin.performAppReload(); + }) + ).addButton((button) => + button + .setButtonText("Fetch w/o restarting") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await rebuildDB("localOnly"); + }) + ) - new Setting(containerMaintenanceEl) - .setName("Reset all journal counter") - .setDesc("Initialise all journal history, On the next sync, every item will be received and sent.") - .addButton((button) => - button - .setButtonText("Reset all") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await this.plugin.getMinioJournalSyncClient().resetCheckpointInfo(); - Logger(`Journal exchange history has been cleared.`, LOG_LEVEL_NOTICE); - }) - ).addOnUpdate(onlyOnMinIO); + new Setting(paneEl) + .setName("Fetch rebuilt DB (Save local documents before)") + .setDesc("Restore or reconstruct local database from remote database but use local chunks.") + .addButton((button) => + button + .setButtonText("Save and Fetch") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await rebuildDB("localOnlyWithChunks"); + }) + ).addOnUpdate(onlyOnCouchDB); - new Setting(containerMaintenanceEl) - .setName("Purge all journal counter") - .setDesc("Purge all sending and downloading cache.") - .addButton((button) => - button - .setButtonText("Reset all") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await this.plugin.getMinioJournalSyncClient().resetAllCaches(); - Logger(`Journal sending and downloading cache has been cleared.`, LOG_LEVEL_NOTICE); - }) - ).addOnUpdate(onlyOnMinIO); + }); - new Setting(containerMaintenanceEl) - .setName("Make empty the bucket") - .setDesc("Delete all data on the remote.") - .addButton((button) => - button - .setButtonText("Delete") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await this.plugin.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({ - ...info, - receivedFiles: new Set(), - knownIDs: new Set(), - lastLocalSeq: 0, - sentIDs: new Set(), - sentFiles: new Set() - })); - await this.plugin.resetRemoteBucket(); - Logger(`the bucket has been cleared.`, LOG_LEVEL_NOTICE); - }) - ).addOnUpdate(onlyOnMinIO); + addPanel(paneEl, "Total Overhaul").then((paneEl) => { + new Setting(paneEl) + .setName("Rebuild everything") + .setDesc("Rebuild local and remote database with local files.") + .addButton((button) => + button + .setButtonText("Rebuild") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await this.plugin.vaultAccess.vaultCreate(FLAGMD_REDFLAG2_HR, ""); + this.plugin.performAppReload(); + }) + ) + .addButton((button) => + button + .setButtonText("Rebuild w/o restarting") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await rebuildDB("rebuildBothByThisDevice"); + }) + ) + }); + addPanel(paneEl, "Rebuilding Operations (Remote Only)").then((paneEl) => { - this.createEl(containerMaintenanceEl, "h4", { text: "Local database" }); + new Setting(paneEl) + .setName("Perform compaction") + .setDesc("Compaction discards all of Eden in the non-latest revisions, reducing the storage usage. However, this operation requires the same free space on the remote as the current database.") + .addButton((button) => + button + .setButtonText("Perform") + .setDisabled(false) + .onClick(async () => { + const replicator = this.plugin.replicator as LiveSyncCouchDBReplicator; + Logger(`Compaction has been began`, LOG_LEVEL_NOTICE, "compaction") + if (await replicator.compactRemote(this.editingSettings)) { + Logger(`Compaction has been completed!`, LOG_LEVEL_NOTICE, "compaction"); + } else { + Logger(`Compaction has been failed!`, LOG_LEVEL_NOTICE, "compaction"); + } + }) + ).addOnUpdate(onlyOnCouchDB); - new Setting(containerMaintenanceEl) - .setName("Fetch from remote") - .setDesc("Restore or reconstruct local database from remote.") - .addButton((button) => - button - .setButtonText("Fetch") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await this.plugin.vaultAccess.vaultCreate(FLAGMD_REDFLAG3_HR, ""); - this.plugin.performAppReload(); - }) - ).addButton((button) => - button - .setButtonText("Fetch w/o restarting") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await rebuildDB("localOnly"); - }) - ) - new Setting(containerMaintenanceEl) - .setName("Fetch rebuilt DB (Save local documents before)") - .setDesc("Restore or reconstruct local database from remote database but use local chunks.") - .addButton((button) => - button - .setButtonText("Save and Fetch") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await rebuildDB("localOnlyWithChunks"); - }) - ).addOnUpdate(onlyOnCouchDB); + new Setting(paneEl) + .setName("Overwrite remote") + .setDesc("Overwrite remote with local DB and passphrase.") + .addButton((button) => + button + .setButtonText("Send") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await rebuildDB("remoteOnly"); + }) + ) - new Setting(containerMaintenanceEl) - .setName("Discard local database to reset or uninstall Self-hosted LiveSync") - .addButton((button) => - button - .setButtonText("Discard") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await this.plugin.resetLocalDatabase(); - await this.plugin.initializeDatabase(); - }) - ); + new Setting(paneEl) + .setName("Reset all journal counter") + .setDesc("Initialise all journal history, On the next sync, every item will be received and sent.") + .addButton((button) => + button + .setButtonText("Reset all") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await this.plugin.getMinioJournalSyncClient().resetCheckpointInfo(); + Logger(`Journal exchange history has been cleared.`, LOG_LEVEL_NOTICE); + }) + ).addOnUpdate(onlyOnMinIO); - this.createEl(containerMaintenanceEl, "h4", { text: "Both databases" }); + new Setting(paneEl) + .setName("Purge all journal counter") + .setDesc("Purge all sending and downloading cache.") + .addButton((button) => + button + .setButtonText("Reset all") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await this.plugin.getMinioJournalSyncClient().resetAllCaches(); + Logger(`Journal sending and downloading cache has been cleared.`, LOG_LEVEL_NOTICE); + }) + ).addOnUpdate(onlyOnMinIO); - new Setting(containerMaintenanceEl) - .setName("(Beta2) Clean up databases") - .setDesc("Delete unused chunks to shrink the database. This feature requires disabling 'Use an old adapter for compatibility'") - .addButton((button) => - button.setButtonText("DryRun") - .setDisabled(false) - .onClick(async () => { - await this.plugin.dryRunGC(); - }) - ).addButton((button) => - button.setButtonText("Perform cleaning") - .setDisabled(false) - .setWarning() - .onClick(async () => { - this.closeSetting() - await this.plugin.dbGC(); - }) - ).addOnUpdate(onlyOnCouchDB); + new Setting(paneEl) + .setName("Make empty the bucket") + .setDesc("Delete all data on the remote.") + .addButton((button) => + button + .setButtonText("Delete") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await this.plugin.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({ + ...info, + receivedFiles: new Set(), + knownIDs: new Set(), + lastLocalSeq: 0, + sentIDs: new Set(), + sentFiles: new Set() + })); + await this.plugin.resetRemoteBucket(); + Logger(`the bucket has been cleared.`, LOG_LEVEL_NOTICE); + }) + ).addOnUpdate(onlyOnMinIO); + }); - new Setting(containerMaintenanceEl) - .setName("Rebuild everything") - .setDesc("Rebuild local and remote database with local files.") - .addButton((button) => - button - .setButtonText("Rebuild") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await this.plugin.vaultAccess.vaultCreate(FLAGMD_REDFLAG2_HR, ""); - this.plugin.performAppReload(); - }) - ) - .addButton((button) => - button - .setButtonText("Rebuild w/o restarting") - .setWarning() - .setDisabled(false) - .onClick(async () => { - await rebuildDB("rebuildBothByThisDevice"); - }) - ) + addPanel(paneEl, "Niches").then((paneEl) => { + new Setting(paneEl) + .setClass("sls-setting-obsolete") + .setName("(Obsolete) Clean up databases") + .setDesc("Delete unused chunks to shrink the database. However, this feature could be not effective in some cases. Please use rebuild everything instead.") + .addButton((button) => + button.setButtonText("DryRun") + .setDisabled(false) + .onClick(async () => { + await this.plugin.dryRunGC(); + }) + ).addButton((button) => + button.setButtonText("Perform cleaning") + .setDisabled(false) + .setWarning() + .onClick(async () => { + this.closeSetting() + await this.plugin.dbGC(); + }) + ).addOnUpdate(onlyOnCouchDB); + }); + addPanel(paneEl, "Reset").then((paneEl) => { + new Setting(paneEl) + .setName("Discard local database to reset or uninstall Self-hosted LiveSync") + .addButton((button) => + button + .setButtonText("Discard") + .setWarning() + .setDisabled(false) + .onClick(async () => { + await this.plugin.resetLocalDatabase(); + await this.plugin.initializeDatabase(); + }) + ); + }); - addScreenElement("70", containerMaintenanceEl); - if (this.selectedScreen == "") { - if (lastVersion != this.editingSettings.lastReadUpdates) { - if (this.editingSettings.isConfigured) { - changeDisplay("100"); + }); + yieldNextAnimationFrame().then(() => { + if (this.selectedScreen == "") { + if (lastVersion != this.editingSettings.lastReadUpdates) { + if (this.editingSettings.isConfigured) { + changeDisplay("100"); + } else { + changeDisplay("110") + } } else { - changeDisplay("110") + if (isAnySyncEnabled()) { + changeDisplay("20"); + } else { + changeDisplay("110") + } } } else { - if (isAnySyncEnabled()) { - changeDisplay("20"); - } else { - changeDisplay("110") - } + changeDisplay(this.selectedScreen); } - } else { - changeDisplay(this.selectedScreen); - } - this.requestUpdate(); + this.requestUpdate(); + }); } } diff --git a/src/ui/components/LiveSyncSetting.ts b/src/ui/components/LiveSyncSetting.ts new file mode 100644 index 0000000..7d945f4 --- /dev/null +++ b/src/ui/components/LiveSyncSetting.ts @@ -0,0 +1,339 @@ +import { Setting, TextComponent, type ToggleComponent, type DropdownComponent, ButtonComponent, type TextAreaComponent, type ValueComponent } from "obsidian"; +import { unique } from "octagonal-wheels/collection"; +import { LEVEL_ADVANCED, LEVEL_POWER_USER, statusDisplay, type ConfigurationItem } from "../../lib/src/common/types"; +import { type ObsidianLiveSyncSettingTab, type AutoWireOption, wrapMemo, type OnUpdateResult, createStub, findAttrFromParent } from "../ObsidianLiveSyncSettingTab"; +import { type AllSettingItemKey, getConfig, type AllSettings, type AllStringItemKey, type AllNumericItemKey, type AllBooleanItemKey } from "../settingConstants"; + + +export class LiveSyncSetting extends Setting { + autoWiredComponent?: TextComponent | ToggleComponent | DropdownComponent | ButtonComponent | TextAreaComponent; + applyButtonComponent?: ButtonComponent; + selfKey?: AllSettingItemKey; + watchDirtyKeys = [] as AllSettingItemKey[]; + holdValue: boolean = false; + static env: ObsidianLiveSyncSettingTab; + + descBuf: string | DocumentFragment = ""; + nameBuf: string | DocumentFragment = ""; + placeHolderBuf: string = ""; + hasPassword: boolean = false; + + invalidateValue?: () => void; + setValue?: (value: any) => void; + constructor(containerEl: HTMLElement) { + super(containerEl); + LiveSyncSetting.env.settingComponents.push(this); + } + + _createDocStub(key: string, value: string | DocumentFragment) { + DEV: { + const paneName = findAttrFromParent(this.settingEl, "data-pane"); + const panelName = findAttrFromParent(this.settingEl, "data-panel"); + const itemName = typeof this.nameBuf == "string" ? this.nameBuf : this.nameBuf.textContent?.toString() ?? ""; + const strValue = typeof value == "string" ? value : value.textContent?.toString() ?? ""; + + createStub(itemName, key, strValue, panelName, paneName); + } + } + + setDesc(desc: string | DocumentFragment): this { + this.descBuf = desc; + DEV: { + this._createDocStub("desc", desc); + } + super.setDesc(desc); + return this; + } + setName(name: string | DocumentFragment): this { + this.nameBuf = name; + DEV: { + this._createDocStub("name", name); + } + super.setName(name); + return this; + } + setAuto(key: AllSettingItemKey, opt?: AutoWireOption) { + this.autoWireSetting(key, opt); + return this; + } + autoWireSetting(key: AllSettingItemKey, opt?: AutoWireOption) { + const conf = getConfig(key); + if (!conf) { + // throw new Error(`No such setting item :${key}`) + return; + } + const name = `${conf.name}${statusDisplay(conf.status)}`; + this.setName(name); + if (conf.desc) { + this.setDesc(conf.desc); + } + DEV: { + this._createDocStub("key", key); + if (conf.obsolete) this._createDocStub("is_obsolete", "true"); + if (conf.level) this._createDocStub("level", conf.level); + } + + this.holdValue = opt?.holdValue || this.holdValue; + this.selfKey = key; + if (conf.obsolete || opt?.obsolete) { + this.settingEl.toggleClass("sls-setting-obsolete", true); + } + if (opt?.onUpdate) this.addOnUpdate(opt.onUpdate); + const stat = this._getComputedStatus(); + if (stat.visibility === false) { + this.settingEl.toggleClass("sls-setting-hidden", !stat.visibility); + } + return conf; + } + autoWireComponent(component: ValueComponent, conf?: ConfigurationItem, opt?: AutoWireOption) { + this.placeHolderBuf = conf?.placeHolder || opt?.placeHolder || ""; + if (conf?.level == LEVEL_ADVANCED) { + this.settingEl.toggleClass("sls-setting-advanced", true); + } else if (conf?.level == LEVEL_POWER_USER) { + this.settingEl.toggleClass("sls-setting-poweruser", true); + } + if (this.placeHolderBuf && component instanceof TextComponent) { + component.setPlaceholder(this.placeHolderBuf); + } + if (opt?.onUpdate) this.addOnUpdate(opt.onUpdate); + } + async commitValue(value: AllSettings[T]) { + const key = this.selfKey as T; + if (key !== undefined) { + if (value != LiveSyncSetting.env.editingSettings[key]) { + LiveSyncSetting.env.editingSettings[key] = value; + if (!this.holdValue) { + await LiveSyncSetting.env.saveSettings([key]); + } + } + } + LiveSyncSetting.env.requestUpdate(); + } + autoWireText(key: AllStringItemKey, opt?: AutoWireOption) { + const conf = this.autoWireSetting(key, opt); + this.addText(text => { + this.autoWiredComponent = text; + const setValue = wrapMemo((value: string) => text.setValue(value)); + this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`); + this.invalidateValue(); + text.onChange(async (value) => { + await this.commitValue(value); + }); + if (opt?.isPassword) { + text.inputEl.setAttribute("type", "password"); + this.hasPassword = true; + } + this.autoWireComponent(this.autoWiredComponent, conf, opt); + }); + return this; + } + autoWireTextArea(key: AllStringItemKey, opt?: AutoWireOption) { + const conf = this.autoWireSetting(key, opt); + this.addTextArea(text => { + this.autoWiredComponent = text; + const setValue = wrapMemo((value: string) => text.setValue(value)); + this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key]}`); + this.invalidateValue(); + text.onChange(async (value) => { + await this.commitValue(value); + }); + if (opt?.isPassword) { + text.inputEl.setAttribute("type", "password"); + this.hasPassword = true; + } + this.autoWireComponent(this.autoWiredComponent, conf, opt); + }); + return this; + } + autoWireNumeric(key: AllNumericItemKey, opt: AutoWireOption & { clampMin?: number; clampMax?: number; acceptZero?: boolean; }) { + const conf = this.autoWireSetting(key, opt); + this.addText(text => { + this.autoWiredComponent = text; + if (opt.clampMin) { + text.inputEl.setAttribute("min", `${opt.clampMin}`); + } + if (opt.clampMax) { + text.inputEl.setAttribute("max", `${opt.clampMax}`); + } + let lastError = false; + const setValue = wrapMemo((value: string) => text.setValue(value)); + this.invalidateValue = () => { + if (!lastError) setValue(`${LiveSyncSetting.env.editingSettings[key]}`); + }; + this.invalidateValue(); + text.onChange(async (TextValue) => { + const parsedValue = Number(TextValue); + const value = parsedValue; + let hasError = false; + if (isNaN(value)) hasError = true; + if (opt.clampMax && opt.clampMax < value) hasError = true; + if (opt.clampMin && opt.clampMin > value) { + if (opt.acceptZero && value == 0) { + // This is ok. + } else { + hasError = true; + } + } + if (!hasError) { + lastError = false; + this.setTooltip(``); + text.inputEl.toggleClass("sls-item-invalid-value", false); + await this.commitValue(value); + } else { + this.setTooltip(`The value should ${opt.clampMin || "~"} < value < ${opt.clampMax || "~"}`); + text.inputEl.toggleClass("sls-item-invalid-value", true); + lastError = true; + return false; + } + }); + text.inputEl.setAttr("type", "number"); + this.autoWireComponent(this.autoWiredComponent, conf, opt); + }); + return this; + } + autoWireToggle(key: AllBooleanItemKey, opt?: AutoWireOption) { + const conf = this.autoWireSetting(key, opt); + this.addToggle(toggle => { + this.autoWiredComponent = toggle; + const setValue = wrapMemo((value: boolean) => toggle.setValue(opt?.invert ? !value : value)); + this.invalidateValue = () => setValue(LiveSyncSetting.env.editingSettings[key] ?? false); + this.invalidateValue(); + + toggle.onChange(async (value) => { + await this.commitValue(opt?.invert ? !value : value); + }); + + this.autoWireComponent(this.autoWiredComponent, conf, opt); + }); + return this; + } + autoWireDropDown(key: AllStringItemKey, opt: AutoWireOption & { options: Record; }) { + const conf = this.autoWireSetting(key, opt); + this.addDropdown(dropdown => { + this.autoWiredComponent = dropdown; + const setValue = wrapMemo((value: string) => { + dropdown.setValue(value); + }); + + dropdown + .addOptions(opt.options); + + this.invalidateValue = () => setValue(LiveSyncSetting.env.editingSettings[key] || ""); + this.invalidateValue(); + dropdown.onChange(async (value) => { + await this.commitValue(value); + }); + this.autoWireComponent(this.autoWiredComponent, conf, opt); + }); + return this; + } + addApplyButton(keys: AllSettingItemKey[], text?: string) { + this.addButton((button) => { + this.applyButtonComponent = button; + this.watchDirtyKeys = unique([...keys, ...this.watchDirtyKeys]); + button.setButtonText(text ?? "Apply"); + button.onClick(async () => { + await LiveSyncSetting.env.saveSettings(keys); + LiveSyncSetting.env.reloadAllSettings(); + }); + LiveSyncSetting.env.requestUpdate(); + }); + return this; + } + addOnUpdate(func: () => OnUpdateResult) { + this.updateHandlers.add(func); + // this._applyOnUpdateHandlers(); + return this; + } + updateHandlers = new Set<() => OnUpdateResult>(); + + prevStatus: OnUpdateResult = {}; + + _getComputedStatus() { + let newConf = {} as OnUpdateResult; + for (const handler of this.updateHandlers) { + newConf = { + ...newConf, + ...handler(), + }; + } + return newConf; + } + _applyOnUpdateHandlers() { + if (this.updateHandlers.size > 0) { + const newConf = this._getComputedStatus(); + const keys = Object.keys(newConf) as [keyof OnUpdateResult]; + for (const k of keys) { + + if (k in this.prevStatus && this.prevStatus[k] == newConf[k]) { + continue; + } + // const newValue = newConf[k]; + switch (k) { + case "visibility": + this.settingEl.toggleClass("sls-setting-hidden", !(newConf[k] || false)); + this.prevStatus[k] = newConf[k]; + break; + case "classes": + break; + case "disabled": + this.setDisabled((newConf[k] || false)); + this.settingEl.toggleClass("sls-setting-disabled", (newConf[k] || false)); + this.prevStatus[k] = newConf[k]; + break; + case "isCta": + { + const component = this.autoWiredComponent; + if (component instanceof ButtonComponent) { + if (newConf[k]) { + component.setCta(); + } else { + component.removeCta(); + } + } + this.prevStatus[k] = newConf[k]; + } + break; + case "isWarning": + { + const component = this.autoWiredComponent; + if (component instanceof ButtonComponent) { + if (newConf[k]) { + component.setWarning(); + } else { + //TODO:IMPLEMENT + // component.removeCta(); + } + } + this.prevStatus[k] = newConf[k]; + } + break; + } + } + } + } + _onUpdate() { + if (this.applyButtonComponent) { + const isDirty = LiveSyncSetting.env.isSomeDirty(this.watchDirtyKeys); + this.applyButtonComponent.setDisabled(!isDirty); + if (isDirty) { + this.applyButtonComponent.setCta(); + } else { + this.applyButtonComponent.removeCta(); + } + } + if (this.selfKey && !LiveSyncSetting.env.isDirty(this.selfKey) && this.invalidateValue) { + this.invalidateValue(); + } + if (this.holdValue && this.selfKey) { + const isDirty = LiveSyncSetting.env.isDirty(this.selfKey); + const alt = isDirty ? `Original: ${LiveSyncSetting.env.initialSettings![this.selfKey]}` : ""; + this.controlEl.toggleClass("sls-item-dirty", isDirty); + if (!this.hasPassword) { + this.nameEl.toggleClass("sls-item-dirty-help", isDirty); + this.setTooltip(alt, { delay: 10, placement: "right" }); + } + } + this._applyOnUpdateHandlers(); + } +} diff --git a/src/ui/settingConstants.ts b/src/ui/settingConstants.ts index 0f6237d..8914f5a 100644 --- a/src/ui/settingConstants.ts +++ b/src/ui/settingConstants.ts @@ -198,8 +198,9 @@ export const SettingInformation: Partial.setting-item-control>input { -webkit-text-security: disc; } @@ -338,4 +403,27 @@ span.ls-mark-cr::after { background: var(--background-modifier-error); -webkit-filter: unset; filter: unset; +} + +.menu-setting-poweruser-disabled .sls-setting-poweruser { + display: none; +} + +.menu-setting-advanced-disabled .sls-setting-advanced { + display: none; +} + +.menu-setting-edgecase-disabled .sls-setting-edgecase { + display: none; +} + +.sls-setting-panel-title { + position: sticky; +} + +.sls-setting-panel-title { + top: 2em; + background-color: rgba(var(--background-primary), 0.3); + backdrop-filter: blur(4px); + border-radius: 30%; } \ No newline at end of file diff --git a/terser.config.mjs b/terser.config.mjs new file mode 100644 index 0000000..7554bfe --- /dev/null +++ b/terser.config.mjs @@ -0,0 +1,61 @@ +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 terserOption = { + sourceMap: !prod + ? { + url: "inline", + } + : {}, + format: { + indent_level: 2, + beautify: true, + comments: "some", + ecma: 2018, + preamble: banner, + webkit: true, + }, + parse: { + // parse options + }, + compress: { + // compress options + defaults: false, + evaluate: true, + dead_code: true, + // directives: true, + // conditionals: true, + inline: 3, + join_vars: true, + loops: true, + passes: 4, + reduce_vars: true, + reduce_funcs: true, + arrows: true, + collapse_vars: true, + comparisons: true, + lhs_constants: true, + hoist_props: true, + side_effects: true, + ecma: 2018, + if_return: true, + unused: true, + }, + mangle: false, + + ecma: 2018, // specify one of: 5, 2015, 2016, etc. + enclose: false, // or specify true, or "args:values" + keep_classnames: true, + keep_fnames: true, + ie8: false, + module: false, + // nameCache: null, // or specify a name cache object + safari10: false, + toplevel: false, +}; + +export { terserOption }; diff --git a/tsconfig.json b/tsconfig.json index 94e5e92..19442a2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,7 @@ "ES7", "es2019.array", "ES2020.BigInt", + "ESNext.Intl" ] }, "include": [