mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-06-04 13:32:57 +00:00
455 lines
25 KiB
TypeScript
455 lines
25 KiB
TypeScript
import {
|
|
type FilePathWithPrefix,
|
|
type DocumentID,
|
|
LOG_LEVEL_NOTICE,
|
|
LOG_LEVEL_VERBOSE,
|
|
type LoadedEntry,
|
|
type MetaEntry,
|
|
type FilePath,
|
|
} from "@lib/common/types.ts";
|
|
import { createBlob, getFileRegExp, isDocContentSame, readAsBlob } from "@lib/common/utils.ts";
|
|
import { Logger } from "@lib/common/logger.ts";
|
|
import { addPrefix, shouldBeIgnored, stripAllPrefixes } from "@lib/string_and_binary/path.ts";
|
|
import { $msg } from "@lib/common/i18n.ts";
|
|
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
|
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
|
import {
|
|
EVENT_ANALYSE_DB_USAGE,
|
|
EVENT_REQUEST_CHECK_REMOTE_SIZE,
|
|
EVENT_REQUEST_RUN_DOCTOR,
|
|
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
|
|
eventHub,
|
|
} from "@/common/events.ts";
|
|
import { ICHeader, ICXHeader, PSCHeader } from "@/common/types.ts";
|
|
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
|
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
|
|
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
|
import type { PageFunctions } from "./SettingPane.ts";
|
|
import { isNotFoundError } from "@lib/common/utils.doc.ts";
|
|
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
|
// 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");
|
|
void addPanel(paneEl, $msg("Setting.TroubleShooting")).then((paneEl) => {
|
|
new Setting(paneEl)
|
|
.setName($msg("Setting.TroubleShooting.Doctor"))
|
|
.setDesc($msg("Setting.TroubleShooting.Doctor.Desc"))
|
|
.addButton((button) =>
|
|
button
|
|
.setButtonText($msg("Run Doctor"))
|
|
.setCta()
|
|
.setDisabled(false)
|
|
.onClick(() => {
|
|
this.closeSetting();
|
|
eventHub.emitEvent(EVENT_REQUEST_RUN_DOCTOR, "you wanted(Thank you)!");
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName($msg("Setting.TroubleShooting.ScanBrokenFiles"))
|
|
.setDesc($msg("Setting.TroubleShooting.ScanBrokenFiles.Desc"))
|
|
.addButton((button) =>
|
|
button
|
|
.setButtonText("Scan for Broken files")
|
|
.setCta()
|
|
.setDisabled(false)
|
|
.onClick(() => {
|
|
this.closeSetting();
|
|
eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE);
|
|
})
|
|
);
|
|
|
|
new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) =>
|
|
button
|
|
.setButtonText($msg("Copy Report to clipboard"))
|
|
.setCta()
|
|
.setDisabled(false)
|
|
.onClick(async () => {
|
|
await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info");
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName($msg("Analyse database usage"))
|
|
.setDesc(
|
|
$msg(
|
|
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like."
|
|
)
|
|
)
|
|
.addButton((button) =>
|
|
button.setButtonText($msg("Analyse")).onClick(() => {
|
|
eventHub.emitEvent(EVENT_ANALYSE_DB_USAGE);
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName($msg("Reset notification threshold and check the remote database usage"))
|
|
.setDesc($msg("Reset the remote storage size threshold and check the remote storage size again."))
|
|
.addButton((button) =>
|
|
button.setButtonText($msg("Check")).onClick(() => {
|
|
eventHub.emitEvent(EVENT_REQUEST_CHECK_REMOTE_SIZE);
|
|
})
|
|
);
|
|
new Setting(paneEl).autoWireToggle("writeLogToTheFile");
|
|
});
|
|
|
|
void addPanel(paneEl, "Scram Switches").then((paneEl) => {
|
|
new Setting(paneEl).autoWireToggle("suspendFileWatching");
|
|
this.addOnSaved("suspendFileWatching", () => this.services.appLifecycle.askRestart());
|
|
|
|
new Setting(paneEl).autoWireToggle("suspendParseReplicationResult");
|
|
this.addOnSaved("suspendParseReplicationResult", () => this.services.appLifecycle.askRestart());
|
|
});
|
|
|
|
void addPanel(paneEl, "Recovery and Repair").then((paneEl) => {
|
|
const addResult = async (path: string, file: FilePathWithPrefix | false, fileOnDB: LoadedEntry | false) => {
|
|
const storageFileStat = file ? await this.core.storageAccess.statHidden(file) : null;
|
|
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: ${!storageFileStat ? `Missing:` : `${new Date(storageFileStat.mtime).toLocaleString()}, Size:${storageFileStat.size}`}`,
|
|
})
|
|
);
|
|
infoGroupEl.appendChild(
|
|
this.createEl(infoGroupEl, "div", {
|
|
text: `Database: Modified: ${!fileOnDB ? `Missing:` : `${new Date(fileOnDB.mtime).toLocaleString()}, Size:${fileOnDB.size} (actual size:${readAsBlob(fileOnDB).size})`}`,
|
|
})
|
|
);
|
|
})
|
|
);
|
|
if (fileOnDB && file) {
|
|
el.appendChild(
|
|
this.createEl(el, "button", { text: "Show history" }, (buttonEl) => {
|
|
buttonEl.onClickEvent(() => {
|
|
eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, {
|
|
file: file,
|
|
fileOnDB: fileOnDB,
|
|
});
|
|
});
|
|
})
|
|
);
|
|
}
|
|
if (file) {
|
|
el.appendChild(
|
|
this.createEl(el, "button", { text: "Storage -> Database" }, (buttonEl) => {
|
|
buttonEl.onClickEvent(async () => {
|
|
if (file.startsWith(".")) {
|
|
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
|
if (addOn) {
|
|
const file = (await addOn.scanInternalFiles()).find((e) => e.path == path);
|
|
if (!file) {
|
|
Logger(
|
|
`Failed to find the file in the internal files: ${path}`,
|
|
LOG_LEVEL_NOTICE
|
|
);
|
|
return;
|
|
}
|
|
if (!(await addOn.storeInternalFileToDatabase(file, true))) {
|
|
Logger(
|
|
`Failed to store the file to the database (Hidden file): ${file.path}`,
|
|
LOG_LEVEL_NOTICE
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
if (!(await this.core.fileHandler.storeFileToDB(file, true))) {
|
|
Logger(
|
|
`Failed to store the file to the database: ${file}`,
|
|
LOG_LEVEL_NOTICE
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
el.remove();
|
|
});
|
|
})
|
|
);
|
|
}
|
|
if (fileOnDB) {
|
|
el.appendChild(
|
|
this.createEl(el, "button", { text: "Database -> Storage" }, (buttonEl) => {
|
|
buttonEl.onClickEvent(async () => {
|
|
if (fileOnDB.path.startsWith(ICHeader)) {
|
|
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
|
if (addOn) {
|
|
if (
|
|
!(await addOn.extractInternalFileFromDatabase(path as FilePath, true))
|
|
) {
|
|
Logger(
|
|
`Failed to store the file to the database (Hidden file): ${file}`,
|
|
LOG_LEVEL_NOTICE
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
if (
|
|
!(await this.core.fileHandler.dbToStorage(
|
|
fileOnDB as MetaEntry,
|
|
null,
|
|
true
|
|
))
|
|
) {
|
|
Logger(
|
|
`Failed to store the file to the storage: ${fileOnDB.path}`,
|
|
LOG_LEVEL_NOTICE
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
el.remove();
|
|
});
|
|
})
|
|
);
|
|
}
|
|
return el;
|
|
})
|
|
);
|
|
};
|
|
|
|
const checkBetweenStorageAndDatabase = async (file: FilePathWithPrefix, fileOnDB: LoadedEntry) => {
|
|
const dataContent = readAsBlob(fileOnDB);
|
|
const content = createBlob(await this.core.storageAccess.readHiddenFileBinary(file));
|
|
if (await isDocContentSame(content, dataContent)) {
|
|
Logger(`Compare: SAME: ${file}`);
|
|
} else {
|
|
Logger(`Compare: CONTENT IS NOT MATCHED! ${file}`, LOG_LEVEL_NOTICE);
|
|
void addResult(file, 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.core.fileHandler.createAllChunks(true);
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName("Resolve All conflicted files by the newer one")
|
|
.setDesc(
|
|
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one."
|
|
)
|
|
.addButton((button) =>
|
|
button
|
|
.setButtonText("Resolve All")
|
|
.setCta()
|
|
.onClick(async () => {
|
|
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
|
|
})
|
|
);
|
|
|
|
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 () => {
|
|
Logger("Start verifying all files", LOG_LEVEL_NOTICE, "verify");
|
|
const ignorePatterns = getFileRegExp(this.core.settings, "syncInternalFilesIgnorePatterns");
|
|
const targetPatterns = getFileRegExp(this.core.settings, "syncInternalFilesTargetPatterns");
|
|
this.core.localDatabase.clearCaches();
|
|
Logger("Start verifying all files", LOG_LEVEL_NOTICE, "verify");
|
|
const files = this.core.settings.syncInternalFiles
|
|
? await this.core.storageAccess.getFilesIncludeHidden("/", targetPatterns, ignorePatterns)
|
|
: await this.core.storageAccess.getFileNames();
|
|
const documents = [] as FilePath[];
|
|
|
|
const adn = this.core.localDatabase.findAllDocs();
|
|
for await (const i of adn) {
|
|
const path = this.services.path.getPath(i);
|
|
if (path.startsWith(ICXHeader)) continue;
|
|
if (path.startsWith(PSCHeader)) continue;
|
|
if (!this.core.settings.syncInternalFiles && path.startsWith(ICHeader)) continue;
|
|
documents.push(stripAllPrefixes(path));
|
|
}
|
|
const allPaths = [...new Set([...documents, ...files])];
|
|
let i = 0;
|
|
const incProc = () => {
|
|
i++;
|
|
if (i % 25 == 0)
|
|
Logger(
|
|
`Checking ${i}/${allPaths.length} files \n`,
|
|
LOG_LEVEL_NOTICE,
|
|
"verify-processed"
|
|
);
|
|
};
|
|
const semaphore = Semaphore(10);
|
|
const processes = allPaths.map(async (path) => {
|
|
try {
|
|
if (shouldBeIgnored(path)) {
|
|
return incProc();
|
|
}
|
|
const stat = (await this.core.storageAccess.isExistsIncludeHidden(path))
|
|
? await this.core.storageAccess.statHidden(path)
|
|
: false;
|
|
const fileOnStorage = stat != null ? stat : false;
|
|
if (!(await this.services.vault.isTargetFile(path))) return incProc();
|
|
const releaser = await semaphore.acquire(1);
|
|
if (fileOnStorage && this.services.vault.isFileSizeTooLarge(fileOnStorage.size))
|
|
return incProc();
|
|
try {
|
|
const isHiddenFile = path.startsWith(".");
|
|
const dbPath = isHiddenFile ? addPrefix(path, ICHeader) : path;
|
|
const fileOnDB = await this.core.localDatabase.getDBEntry(dbPath);
|
|
if (fileOnDB && this.services.vault.isFileSizeTooLarge(fileOnDB.size))
|
|
return incProc();
|
|
|
|
if (!fileOnDB && fileOnStorage) {
|
|
Logger(`Compare: Not found on the local database: ${path}`, LOG_LEVEL_NOTICE);
|
|
void addResult(path, path, false);
|
|
return incProc();
|
|
}
|
|
if (fileOnDB && !fileOnStorage) {
|
|
Logger(`Compare: Not found on the storage: ${path}`, LOG_LEVEL_NOTICE);
|
|
void addResult(path, false, fileOnDB);
|
|
return incProc();
|
|
}
|
|
if (fileOnStorage && fileOnDB) {
|
|
await checkBetweenStorageAndDatabase(path, 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);
|
|
}
|
|
});
|
|
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.core.localDatabase.findAllDocNames()) {
|
|
if (!docName.startsWith("f:")) {
|
|
const idEncoded = await this.services.path.path2id(docName as FilePathWithPrefix);
|
|
const doc = await this.core.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.core.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.core.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.core.localDatabase.putRaw(doc)).ok) {
|
|
Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE);
|
|
}
|
|
await this.services.conflict.queueCheckForIfOpen(docName as FilePathWithPrefix);
|
|
} else {
|
|
Logger(`Converting ${docName} Failed!`, LOG_LEVEL_NOTICE);
|
|
Logger(ret, LOG_LEVEL_VERBOSE);
|
|
}
|
|
} catch (ex: unknown) {
|
|
if (isNotFoundError(ex)) {
|
|
// We can perform this safely
|
|
if ((await this.core.localDatabase.putRaw(newDoc)).ok) {
|
|
Logger(`${docName} has been converted`, LOG_LEVEL_NOTICE);
|
|
doc._deleted = true;
|
|
if ((await this.core.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.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Logger(`Converting finished`, LOG_LEVEL_NOTICE);
|
|
})
|
|
);
|
|
});
|
|
void 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.services.appLifecycle.askRestart();
|
|
})
|
|
);
|
|
|
|
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.core.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.core.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
|
|
);
|
|
})
|
|
);
|
|
});
|
|
}
|