mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-12 10:35:25 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
331acd463d | ||
|
|
9d4f41bbf9 | ||
|
|
8831165965 | ||
|
|
ed62e9331b | ||
|
|
799e604eb2 | ||
|
|
d9b69d9a1b | ||
|
|
c18b5c24b4 | ||
|
|
07f16e3d7d |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "obsidian-livesync",
|
"id": "obsidian-livesync",
|
||||||
"name": "Self-hosted LiveSync",
|
"name": "Self-hosted LiveSync",
|
||||||
"version": "0.16.0",
|
"version": "0.16.4",
|
||||||
"minAppVersion": "0.9.12",
|
"minAppVersion": "0.9.12",
|
||||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.16.0",
|
"version": "0.16.4",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.16.0",
|
"version": "0.16.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"diff-match-patch": "^1.0.5",
|
"diff-match-patch": "^1.0.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.16.0",
|
"version": "0.16.4",
|
||||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Logger } from "./lib/src/logger.js";
|
|||||||
import { PouchDB } from "./lib/src/pouchdb-browser.js";
|
import { PouchDB } from "./lib/src/pouchdb-browser.js";
|
||||||
import { EntryDoc, LOG_LEVEL } from "./lib/src/types.js";
|
import { EntryDoc, LOG_LEVEL } from "./lib/src/types.js";
|
||||||
import { enableEncryption } from "./lib/src/utils.js";
|
import { enableEncryption } from "./lib/src/utils.js";
|
||||||
import { isValidRemoteCouchDBURI } from "./lib/src/utils_couchdb.js";
|
import { isCloudantURI, isValidRemoteCouchDBURI } from "./lib/src/utils_couchdb.js";
|
||||||
import { id2path, path2id } from "./utils.js";
|
import { id2path, path2id } from "./utils.js";
|
||||||
|
|
||||||
export class LocalPouchDB extends LocalPouchDBBase {
|
export class LocalPouchDB extends LocalPouchDBBase {
|
||||||
@@ -77,7 +77,7 @@ export class LocalPouchDB extends LocalPouchDBBase {
|
|||||||
const opts_length = opts.body.toString().length;
|
const opts_length = opts.body.toString().length;
|
||||||
if (opts_length > 1024 * 1024 * 10) {
|
if (opts_length > 1024 * 1024 * 10) {
|
||||||
// over 10MB
|
// over 10MB
|
||||||
if (uri.contains(".cloudantnosqldb.")) {
|
if (isCloudantURI(uri)) {
|
||||||
this.last_successful_post = false;
|
this.last_successful_post = false;
|
||||||
Logger("This request should fail on IBM Cloudant.", LOG_LEVEL.VERBOSE);
|
Logger("This request should fail on IBM Cloudant.", LOG_LEVEL.VERBOSE);
|
||||||
throw new Error("This request should fail on IBM Cloudant.");
|
throw new Error("This request should fail on IBM Cloudant.");
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { App, PluginSettingTab, Setting, sanitizeHTMLToDom, RequestUrlParam, requestUrl, TextAreaComponent, MarkdownRenderer } from "obsidian";
|
import { App, PluginSettingTab, Setting, sanitizeHTMLToDom, RequestUrlParam, requestUrl, TextAreaComponent, MarkdownRenderer, stringifyYaml } from "obsidian";
|
||||||
import { DEFAULT_SETTINGS, LOG_LEVEL, RemoteDBSettings } from "./lib/src/types";
|
import { DEFAULT_SETTINGS, LOG_LEVEL, ObsidianLiveSyncSettings, RemoteDBSettings } from "./lib/src/types";
|
||||||
import { path2id, id2path } from "./utils";
|
import { path2id, id2path } from "./utils";
|
||||||
import { delay, versionNumberString2Number } from "./lib/src/utils";
|
import { delay, versionNumberString2Number } from "./lib/src/utils";
|
||||||
import { Logger } from "./lib/src/logger";
|
import { Logger } from "./lib/src/logger";
|
||||||
import { checkSyncInfo } from "./lib/src/utils_couchdb.js";
|
import { checkSyncInfo, isCloudantURI } from "./lib/src/utils_couchdb.js";
|
||||||
import { testCrypt } from "./lib/src/e2ee_v2";
|
import { testCrypt } from "./lib/src/e2ee_v2";
|
||||||
import ObsidianLiveSyncPlugin from "./main";
|
import ObsidianLiveSyncPlugin from "./main";
|
||||||
|
|
||||||
|
const requestToCouchDB = async (baseUri: string, username: string, password: string, origin: string, key?: string, body?: string) => {
|
||||||
|
const utf8str = String.fromCharCode.apply(null, new TextEncoder().encode(`${username}:${password}`));
|
||||||
|
const encoded = window.btoa(utf8str);
|
||||||
|
const authHeader = "Basic " + encoded;
|
||||||
|
// const origin = "capacitor://localhost";
|
||||||
|
const transformedHeaders: Record<string, string> = { authorization: authHeader, origin: origin };
|
||||||
|
const uri = `${baseUri}/_node/_local/_config${key ? "/" + key : ""}`;
|
||||||
|
|
||||||
|
const requestParam: RequestUrlParam = {
|
||||||
|
url: uri,
|
||||||
|
method: body ? "PUT" : "GET",
|
||||||
|
headers: transformedHeaders,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
};
|
||||||
|
return await requestUrl(requestParam);
|
||||||
|
};
|
||||||
export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||||
plugin: ObsidianLiveSyncPlugin;
|
plugin: ObsidianLiveSyncPlugin;
|
||||||
|
|
||||||
@@ -474,23 +491,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
.onClick(async () => {
|
.onClick(async () => {
|
||||||
const checkConfig = async () => {
|
const checkConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const requestToCouchDB = async (baseUri: string, username: string, password: string, origin: string, key?: string, body?: string) => {
|
if (isCloudantURI(this.plugin.settings.couchDB_URI)) {
|
||||||
const utf8str = String.fromCharCode.apply(null, new TextEncoder().encode(`${username}:${password}`));
|
Logger("This feature cannot be used with IBM Cloudant.", LOG_LEVEL.NOTICE);
|
||||||
const encoded = window.btoa(utf8str);
|
return;
|
||||||
const authHeader = "Basic " + encoded;
|
}
|
||||||
// const origin = "capacitor://localhost";
|
|
||||||
const transformedHeaders: Record<string, string> = { authorization: authHeader, origin: origin };
|
|
||||||
const uri = `${baseUri}/_node/_local/_config${key ? "/" + key : ""}`;
|
|
||||||
|
|
||||||
const requestParam: RequestUrlParam = {
|
|
||||||
url: uri,
|
|
||||||
method: body ? "PUT" : "GET",
|
|
||||||
headers: transformedHeaders,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
};
|
|
||||||
return await requestUrl(requestParam);
|
|
||||||
};
|
|
||||||
|
|
||||||
const r = await requestToCouchDB(this.plugin.settings.couchDB_URI, this.plugin.settings.couchDB_USER, this.plugin.settings.couchDB_PASSWORD, window.origin);
|
const r = await requestToCouchDB(this.plugin.settings.couchDB_URI, this.plugin.settings.couchDB_USER, this.plugin.settings.couchDB_PASSWORD, window.origin);
|
||||||
|
|
||||||
@@ -573,7 +577,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
addResult("✔ httpd.enable_cors is ok.");
|
addResult("✔ httpd.enable_cors is ok.");
|
||||||
}
|
}
|
||||||
// If the server is not cloudant, configure request size
|
// If the server is not cloudant, configure request size
|
||||||
if (!this.plugin.settings.couchDB_URI.contains(".cloudantnosqldb.")) {
|
if (!isCloudantURI(this.plugin.settings.couchDB_URI)) {
|
||||||
// REQUEST SIZE
|
// REQUEST SIZE
|
||||||
if (Number(responseConfig?.chttpd?.max_http_request_size ?? 0) < 4294967296) {
|
if (Number(responseConfig?.chttpd?.max_http_request_size ?? 0) < 4294967296) {
|
||||||
addResult("❗ chttpd.max_http_request_size is low)");
|
addResult("❗ chttpd.max_http_request_size is low)");
|
||||||
@@ -637,7 +641,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
addResult("--Done--", ["ob-btn-config-head"]);
|
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"]);
|
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"]);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
Logger(`Checking configuration failed`);
|
Logger(`Checking configuration failed`, LOG_LEVEL.NOTICE);
|
||||||
Logger(ex);
|
Logger(ex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -675,7 +679,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
if (!this.plugin.settings.encrypt) {
|
if (!this.plugin.settings.encrypt) {
|
||||||
this.plugin.settings.passphrase = "";
|
this.plugin.settings.passphrase = "";
|
||||||
}
|
}
|
||||||
if (this.plugin.settings.couchDB_URI.contains(".cloudantnosqldb.")) {
|
if (isCloudantURI(this.plugin.settings.couchDB_URI)) {
|
||||||
this.plugin.settings.customChunkSize = 0;
|
this.plugin.settings.customChunkSize = 0;
|
||||||
} else {
|
} else {
|
||||||
this.plugin.settings.customChunkSize = 100;
|
this.plugin.settings.customChunkSize = 100;
|
||||||
@@ -696,7 +700,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
if (!this.plugin.settings.encrypt) {
|
if (!this.plugin.settings.encrypt) {
|
||||||
this.plugin.settings.passphrase = "";
|
this.plugin.settings.passphrase = "";
|
||||||
}
|
}
|
||||||
if (this.plugin.settings.couchDB_URI.contains(".cloudantnosqldb.")) {
|
if (isCloudantURI(this.plugin.settings.couchDB_URI)) {
|
||||||
this.plugin.settings.customChunkSize = 0;
|
this.plugin.settings.customChunkSize = 0;
|
||||||
} else {
|
} else {
|
||||||
this.plugin.settings.customChunkSize = 100;
|
this.plugin.settings.customChunkSize = 100;
|
||||||
@@ -1263,6 +1267,50 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
containerHatchEl.createEl("h3", { text: "Hatch" });
|
containerHatchEl.createEl("h3", { text: "Hatch" });
|
||||||
|
|
||||||
|
|
||||||
|
new Setting(containerHatchEl)
|
||||||
|
.setName("Make report to inform the issue")
|
||||||
|
.setDesc("Verify and repair all files and update database without restoring")
|
||||||
|
.addButton((button) =>
|
||||||
|
button
|
||||||
|
.setButtonText("Make report")
|
||||||
|
.setDisabled(false)
|
||||||
|
.onClick(async () => {
|
||||||
|
let responseConfig: any = {};
|
||||||
|
const REDACTED = "𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷";
|
||||||
|
try {
|
||||||
|
const r = await requestToCouchDB(this.plugin.settings.couchDB_URI, this.plugin.settings.couchDB_USER, this.plugin.settings.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 to the remote CouchDB has been failed. If you are using IBM Cloudant, it is the normal behaviour."
|
||||||
|
}
|
||||||
|
const pluginConfig = JSON.parse(JSON.stringify(this.plugin.settings)) as ObsidianLiveSyncSettings;
|
||||||
|
pluginConfig.couchDB_DBNAME = REDACTED;
|
||||||
|
pluginConfig.couchDB_PASSWORD = REDACTED;
|
||||||
|
pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) ? "cloudant" : "self-hosted";
|
||||||
|
pluginConfig.couchDB_USER = REDACTED;
|
||||||
|
pluginConfig.passphrase = REDACTED;
|
||||||
|
pluginConfig.workingPassphrase = REDACTED;
|
||||||
|
|
||||||
|
const msgConfig = `----remote config----
|
||||||
|
${stringifyYaml(responseConfig)}
|
||||||
|
---- Plug-in config ---
|
||||||
|
${stringifyYaml(pluginConfig)}`;
|
||||||
|
console.log(msgConfig);
|
||||||
|
await navigator.clipboard.writeText(msgConfig);
|
||||||
|
Logger(`Information has been copied to clipboard`, LOG_LEVEL.NOTICE);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (this.plugin.localDatabase.remoteLockedAndDeviceNotAccepted) {
|
if (this.plugin.localDatabase.remoteLockedAndDeviceNotAccepted) {
|
||||||
const c = containerHatchEl.createEl("div", {
|
const c = containerHatchEl.createEl("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 local database, and press 'Mark this device as resolved'. ",
|
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 local database, and press 'Mark this device as resolved'. ",
|
||||||
|
|||||||
2
src/lib
2
src/lib
Submodule src/lib updated: 564da310e2...b8a765f8e8
76
src/main.ts
76
src/main.ts
@@ -35,6 +35,7 @@ import { decrypt, encrypt } from "./lib/src/e2ee_v2";
|
|||||||
const isDebug = false;
|
const isDebug = false;
|
||||||
|
|
||||||
import { InputStringDialog, PluginDialogModal, PopoverSelectString } from "./dialogs";
|
import { InputStringDialog, PluginDialogModal, PopoverSelectString } from "./dialogs";
|
||||||
|
import { isCloudantURI } from "./lib/src/utils_couchdb";
|
||||||
|
|
||||||
setNoticeClass(Notice);
|
setNoticeClass(Notice);
|
||||||
|
|
||||||
@@ -697,6 +698,10 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
|||||||
this.settings.deviceAndVaultName = "";
|
this.settings.deviceAndVaultName = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isCloudantURI(this.settings.couchDB_URI) && this.settings.customChunkSize != 0) {
|
||||||
|
Logger("Configuration verification founds problems with your configuration. This has been fixed automatically. But you may already have data that cannot be synchronised. If this is the case, please rebuild everything.", LOG_LEVEL.NOTICE)
|
||||||
|
this.settings.customChunkSize = 0;
|
||||||
|
}
|
||||||
this.deviceAndVaultName = localStorage.getItem(lsKey) || "";
|
this.deviceAndVaultName = localStorage.getItem(lsKey) || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,42 +846,45 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearTrigger("applyBatchAuto");
|
clearTrigger("applyBatchAuto");
|
||||||
const ret = await runWithLock("procFiles", false, async () => {
|
const ret = await runWithLock("procFiles", true, async () => {
|
||||||
const procs = [...this.watchedFileEventQueue];
|
do {
|
||||||
this.watchedFileEventQueue = [];
|
const procs = [...this.watchedFileEventQueue];
|
||||||
for (const queue of procs) {
|
this.watchedFileEventQueue = [];
|
||||||
const file = queue.args.file;
|
for (const queue of procs) {
|
||||||
const key = `file-last-proc-${queue.type}-${file.path}`;
|
const file = queue.args.file;
|
||||||
const last = Number(await this.localDatabase.kvDB.get(key) || 0);
|
const key = `file-last-proc-${queue.type}-${file.path}`;
|
||||||
if (file instanceof TFile && file.stat.mtime == last) {
|
const last = Number(await this.localDatabase.kvDB.get(key) || 0);
|
||||||
Logger(`File has been already scanned on ${queue.type}, skip: ${file.path}`, LOG_LEVEL.VERBOSE);
|
if (file instanceof TFile && file.stat.mtime == last) {
|
||||||
continue;
|
Logger(`File has been already scanned on ${queue.type}, skip: ${file.path}`, LOG_LEVEL.VERBOSE);
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const cache = queue.args.cache;
|
const cache = queue.args.cache;
|
||||||
if ((queue.type == "CREATE" || queue.type == "CHANGED") && file instanceof TFile) {
|
if ((queue.type == "CREATE" || queue.type == "CHANGED") && file instanceof TFile) {
|
||||||
await this.updateIntoDB(file, false, cache);
|
await this.updateIntoDB(file, false, cache);
|
||||||
}
|
}
|
||||||
if (queue.type == "DELETE") {
|
if (queue.type == "DELETE") {
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
await this.deleteFromDB(file);
|
||||||
|
} else if (file instanceof TFolder) {
|
||||||
|
await this.deleteFolderOnDB(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queue.type == "RENAME") {
|
||||||
|
if (file instanceof TFile) {
|
||||||
|
await this.watchVaultRenameAsync(file, queue.args.oldPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queue.type == "INTERNAL") {
|
||||||
|
await this.watchVaultRawEventsAsync(file.path);
|
||||||
|
}
|
||||||
if (file instanceof TFile) {
|
if (file instanceof TFile) {
|
||||||
await this.deleteFromDB(file);
|
await this.localDatabase.kvDB.set(key, file.stat.mtime);
|
||||||
} else if (file instanceof TFolder) {
|
|
||||||
await this.deleteFolderOnDB(file);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (queue.type == "RENAME") {
|
this.refreshStatusText();
|
||||||
if (file instanceof TFile) {
|
} while (this.watchedFileEventQueue.length != 0);
|
||||||
await this.watchVaultRenameAsync(file, queue.args.oldPath);
|
return true;
|
||||||
}
|
|
||||||
}
|
|
||||||
if (queue.type == "INTERNAL") {
|
|
||||||
await this.watchVaultRawEventsAsync(file.path);
|
|
||||||
}
|
|
||||||
if (file instanceof TFile) {
|
|
||||||
await this.localDatabase.kvDB.set(key, file.stat.mtime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.refreshStatusText();
|
|
||||||
})
|
})
|
||||||
this.refreshStatusText();
|
this.refreshStatusText();
|
||||||
return ret;
|
return ret;
|
||||||
@@ -917,7 +925,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async applyBatchChange() {
|
async applyBatchChange() {
|
||||||
return await this.procFileEvent(true);
|
if (this.settings.batchSave) {
|
||||||
|
return await this.procFileEvent(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch raw events (Internal API)
|
// Watch raw events (Internal API)
|
||||||
|
|||||||
11
updates.md
11
updates.md
@@ -3,8 +3,17 @@
|
|||||||
- If you want it to back to its previous behaviour, please disable `Monitor changes to internal files`.
|
- If you want it to back to its previous behaviour, please disable `Monitor changes to internal files`.
|
||||||
- Due to using an internal API, this feature may become unusable with a major update. If this happens, please disable this once.
|
- Due to using an internal API, this feature may become unusable with a major update. If this happens, please disable this once.
|
||||||
|
|
||||||
|
#### Minors
|
||||||
|
|
||||||
|
- 0.16.1 Added missing log updates.
|
||||||
|
- 0.16.2 Fixed many problems caused by combinations of `Sync On Save` and the tracking logic that changed at 0.15.6.
|
||||||
|
- 0.16.3
|
||||||
|
- Fixed detection of IBM Cloudant (And if there are some issues, be fixed automatically).
|
||||||
|
- A configuration information reporting tool has been implemented.
|
||||||
|
- 0.16.4 Fixed detection failure. Please set the `Chunk size` again when using a self-hosted database.
|
||||||
|
|
||||||
### 0.15.0
|
### 0.15.0
|
||||||
- Outdated configuration items have been removed.
|
- Outdated configuration items have been removed.
|
||||||
- Setup wizard has been implemented!
|
- Setup wizard has been implemented!
|
||||||
|
|
||||||
I appreciate for reviewing and giving me advice @Pouhon158!
|
I appreciate for reviewing and giving me advice @Pouhon158!
|
||||||
|
|||||||
Reference in New Issue
Block a user