Compare commits

...

2 Commits

Author SHA1 Message Date
vorotamoroz
b8edc85528 bump 2024-07-25 13:37:34 +01:00
vorotamoroz
e2740cbefe New feature:
- Per-file-saved customization sync has been shipped.
- Customisation sync has got beta3.
Improved:
- Start-up speed has been improved.
Fixed:
- On the customisation sync dialogue, buttons are kept within the screen.
- No more unnecessary entries on `data.json` for customisation sync.
- Selections are no longer lost while updating customisation items.
Tidied on source codes:
- Many typos have been fixed.
- Some unnecessary type casting removed.
2024-07-25 13:36:26 +01:00
15 changed files with 1194 additions and 339 deletions

View File

@@ -1,7 +1,7 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "0.23.17", "version": "0.23.18",
"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
View File

@@ -1,12 +1,12 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.23.17", "version": "0.23.18",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.23.17", "version": "0.23.18",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.614.0", "@aws-sdk/client-s3": "^3.614.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.23.17", "version": "0.23.18",
"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",

View File

@@ -19,7 +19,10 @@ export class PluginDialogModal extends Modal {
onOpen() { onOpen() {
const { contentEl } = this; const { contentEl } = this;
this.titleEl.setText("Customization Sync (Beta2)") this.contentEl.style.overflow = "auto";
this.contentEl.style.display = "flex";
this.contentEl.style.flexDirection = "column";
this.titleEl.setText("Customization Sync (Beta3)")
if (!this.component) { if (!this.component) {
this.component = new PluginPane({ this.component = new PluginPane({
target: contentEl, target: contentEl,

View File

@@ -1,10 +1,10 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { Notice, type PluginManifest, parseYaml, normalizePath, type ListedFiles } from "../deps.ts"; import { Notice, type PluginManifest, parseYaml, normalizePath, type ListedFiles, diff_match_patch } from "../deps.ts";
import type { EntryDoc, LoadedEntry, InternalFileEntry, FilePathWithPrefix, FilePath, DocumentID, AnyEntry, SavingEntry } from "../lib/src/common/types.ts"; import type { EntryDoc, LoadedEntry, InternalFileEntry, FilePathWithPrefix, FilePath, AnyEntry, SavingEntry, diff_result } from "../lib/src/common/types.ts";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, MODE_SELECTIVE } from "../lib/src/common/types.ts"; import { CANCELLED, LEAVE_TO_SUBSEQUENT, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, MODE_SELECTIVE, MODE_SHINY } from "../lib/src/common/types.ts";
import { ICXHeader, PERIODIC_PLUGIN_SWEEP, } from "../common/types.ts"; import { ICXHeader, PERIODIC_PLUGIN_SWEEP, } from "../common/types.ts";
import { createSavingEntryFromLoadedEntry, createTextBlob, delay, fireAndForget, getDocDataAsArray, isDocContentSame } from "../lib/src/common/utils.ts"; import { createBlob, createSavingEntryFromLoadedEntry, createTextBlob, delay, fireAndForget, getDocData, getDocDataAsArray, isDocContentSame, isLoadedEntry, isObjectDifferent } from "../lib/src/common/utils.ts";
import { Logger } from "../lib/src/common/logger.ts"; import { Logger } from "../lib/src/common/logger.ts";
import { digestHash } from "../lib/src/string_and_binary/hash.ts"; import { digestHash } from "../lib/src/string_and_binary/hash.ts";
import { arrayBufferToBase64, decodeBinary, readString } from 'src/lib/src/string_and_binary/convert.ts'; import { arrayBufferToBase64, decodeBinary, readString } from 'src/lib/src/string_and_binary/convert.ts';
@@ -17,11 +17,13 @@ import { JsonResolveModal } from "../ui/JsonResolveModal.ts";
import { QueueProcessor } from '../lib/src/concurrency/processor.ts'; import { QueueProcessor } from '../lib/src/concurrency/processor.ts';
import { pluginScanningCount } from '../lib/src/mock_and_interop/stores.ts'; import { pluginScanningCount } from '../lib/src/mock_and_interop/stores.ts';
import type ObsidianLiveSyncPlugin from '../main.ts'; import type ObsidianLiveSyncPlugin from '../main.ts';
import { base64ToArrayBuffer, base64ToString } from 'octagonal-wheels/binary/base64';
import { ConflictResolveModal } from '../ui/ConflictResolveModal.ts';
import { Semaphore } from 'octagonal-wheels/concurrency/semaphore';
const d = "\u200b"; const d = "\u200b";
const d2 = "\n"; const d2 = "\n";
function serialize(data: PluginDataEx): string { function serialize(data: PluginDataEx): string {
// For higher performance, create custom plug-in data strings. // For higher performance, create custom plug-in data strings.
// Self-hosted LiveSync uses `\n` to split chunks. Therefore, grouping together those with similar entropy would work nicely. // Self-hosted LiveSync uses `\n` to split chunks. Therefore, grouping together those with similar entropy would work nicely.
@@ -41,7 +43,15 @@ function serialize(data: PluginDataEx): string {
} }
return ret; return ret;
} }
const DUMMY_HEAD = serialize({
category: "CONFIG",
name: "migrated",
files: [],
mtime: 0,
term: "-",
displayName: `MIRAGED`
});
const DUMMY_END = d + d2 + "\u200c";
function splitWithDelimiters(sources: string[]): string[] { function splitWithDelimiters(sources: string[]): string[] {
const result: string[] = []; const result: string[] = [];
for (const str of sources) { for (const str of sources) {
@@ -186,6 +196,7 @@ function deserialize<T>(str: string[], def: T) {
export const pluginList = writable([] as PluginDataExDisplay[]); export const pluginList = writable([] as PluginDataExDisplay[]);
export const pluginIsEnumerating = writable(false); export const pluginIsEnumerating = writable(false);
export const pluginV2Progress = writable(0);
export type PluginDataExFile = { export type PluginDataExFile = {
filename: string, filename: string,
@@ -196,6 +207,16 @@ export type PluginDataExFile = {
hash?: string, hash?: string,
displayName?: string, displayName?: string,
} }
export interface IPluginDataExDisplay {
documentPath: FilePathWithPrefix;
category: string;
name: string;
term: string;
displayName?: string;
files: (LoadedEntryPluginDataExFile | PluginDataExFile)[];
version?: string;
mtime: number;
}
export type PluginDataExDisplay = { export type PluginDataExDisplay = {
documentPath: FilePathWithPrefix, documentPath: FilePathWithPrefix,
category: string, category: string,
@@ -206,6 +227,88 @@ export type PluginDataExDisplay = {
version?: string, version?: string,
mtime: number, mtime: number,
} }
type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile;
function categoryToFolder(category: string, configDir: string = ""): string {
switch (category) {
case "CONFIG": return `${configDir}/`;
case "THEME": return `${configDir}/themes/`;
case "SNIPPET": return `${configDir}/snippets/`;
case "PLUGIN_MAIN": return `${configDir}/plugins/`;
case "PLUGIN_DATA": return `${configDir}/plugins/`;
case "PLUGIN_ETC": return `${configDir}/plugins/`;
default: return "";
}
}
export const pluginManifests = new Map<string, PluginManifest>();
export const pluginManifestStore = writable(pluginManifests);
function setManifest(key: string, manifest: PluginManifest) {
const old = pluginManifests.get(key);
if (old && !isObjectDifferent(manifest, old)) {
return;
}
pluginManifests.set(key, manifest);
pluginManifestStore.set(pluginManifests);
}
export class PluginDataExDisplayV2 {
documentPath: FilePathWithPrefix;
category: string;
term: string;
files = [] as LoadedEntryPluginDataExFile[];
name: string;
confKey: string;
constructor(data: IPluginDataExDisplay) {
this.documentPath = `${data.documentPath}` as FilePathWithPrefix;
this.category = `${data.category}`;
this.name = `${data.name}`;
this.term = `${data.term}`;
this.files = [...data.files as LoadedEntryPluginDataExFile[]];
this.confKey = `${categoryToFolder(this.category, this.term)}${this.name}`;
this.applyLoadedManifest();
}
setFile(file: LoadedEntryPluginDataExFile) {
if (this.files.find(e => e.filename == file.filename)) {
this.files = this.files.filter(e => e.filename != file.filename);
}
this.files.push(file);
if (file.filename == "manifest.json") {
this.applyLoadedManifest();
}
}
deleteFile(filename: string) {
this.files = this.files.filter(e => e.filename != filename);
}
_displayName: string | undefined;
_version: string | undefined;
applyLoadedManifest() {
const manifest = pluginManifests.get(this.confKey);
if (manifest) {
this._displayName = manifest.name;
if (this.category == "PLUGIN_MAIN" || this.category == "THEME") {
this._version = manifest?.version;
}
}
}
get displayName(): string {
// if (this._displayNameBuffer !== symbolUnInitialised) return this._displayNameBuffer;
// return this._bufferManifest().displayName;
return this._displayName || this.name;
}
get version(): string | undefined {
return this._version;
}
get mtime(): number {
return ~~this.files.reduce((a, b) => a + b.mtime, 0) / this.files.length;
}
}
export type PluginDataEx = { export type PluginDataEx = {
documentPath?: FilePathWithPrefix, documentPath?: FilePathWithPrefix,
category: string, category: string,
@@ -222,19 +325,23 @@ export class ConfigSync extends LiveSyncCommands {
pluginScanningCount.onChanged((e) => { pluginScanningCount.onChanged((e) => {
const total = e.value; const total = e.value;
pluginIsEnumerating.set(total != 0); pluginIsEnumerating.set(total != 0);
// if (total == 0) {
// Logger(`Processing configurations done`, LOG_LEVEL_INFO, "get-plugins");
// }
}) })
} }
get kvDB() { get kvDB() {
return this.plugin.kvDB; return this.plugin.kvDB;
} }
get useV2() {
return this.plugin.settings.usePluginSyncV2;
}
get useSyncPluginEtc() {
return this.plugin.settings.usePluginEtc;
}
pluginDialog?: PluginDialogModal = undefined; pluginDialog?: PluginDialogModal = undefined;
periodicPluginSweepProcessor = new PeriodicProcessor(this.plugin, async () => await this.scanAllConfigFiles(false)); periodicPluginSweepProcessor = new PeriodicProcessor(this.plugin, async () => await this.scanAllConfigFiles(false));
pluginList: PluginDataExDisplay[] = []; pluginList: IPluginDataExDisplay[] = [];
showPluginSyncModal() { showPluginSyncModal() {
if (!this.settings.usePluginSync) { if (!this.settings.usePluginSync) {
return; return;
@@ -277,10 +384,8 @@ export class ConfigSync extends LiveSyncCommands {
} else if (filePath.endsWith("/data.json")) { } else if (filePath.endsWith("/data.json")) {
return "PLUGIN_DATA"; return "PLUGIN_DATA";
} else { } else {
//TODO: to be configurable. // Planned at v0.19.0, realised v0.23.18!
// With algorithm which implemented at v0.19.0, is too heavy. return (this.useV2 && this.useSyncPluginEtc) ? "PLUGIN_ETC" : "";
return "";
// return "PLUGIN_ETC";
} }
// return "PLUGIN"; // return "PLUGIN";
} }
@@ -321,6 +426,7 @@ export class ConfigSync extends LiveSyncCommands {
} }
async reloadPluginList(showMessage: boolean) { async reloadPluginList(showMessage: boolean) {
this.pluginList = []; this.pluginList = [];
this.loadedManifest_mTime.clear();
pluginList.set(this.pluginList) pluginList.set(this.pluginList)
await this.updatePluginList(showMessage); await this.updatePluginList(showMessage);
} }
@@ -355,30 +461,36 @@ export class ConfigSync extends LiveSyncCommands {
} }
return false; return false;
} }
async createMissingConfigurationEntry() {
let saveRequired = false;
for (const v of this.pluginList) {
const key = `${v.category}/${v.name}`;
if (!(key in this.plugin.settings.pluginSyncExtendedSetting)) {
this.plugin.settings.pluginSyncExtendedSetting[key] = {
key,
mode: MODE_SELECTIVE,
files: []
}
}
if (this.plugin.settings.pluginSyncExtendedSetting[key].files.sort().join(",").toLowerCase() !=
v.files.map(e => e.filename).sort().join(",").toLowerCase()) {
this.plugin.settings.pluginSyncExtendedSetting[key].files = v.files.map(e => e.filename).sort();
saveRequired = true;
}
}
if (saveRequired) {
await this.plugin.saveSettingData();
}
}
pluginScanProcessor = new QueueProcessor(async (v: AnyEntry[]) => { pluginScanProcessor = new QueueProcessor(async (v: AnyEntry[]) => {
const plugin = v[0];
if (this.useV2) {
await this.migrateV1ToV2(false, plugin);
return [];
}
const path = plugin.path || this.getPath(plugin);
const oldEntry = (this.pluginList.find(e => e.documentPath == path));
if (oldEntry && oldEntry.mtime == plugin.mtime) return [];
try {
const pluginData = await this.loadPluginData(path);
if (pluginData) {
let newList = [...this.pluginList];
newList = newList.filter(x => x.documentPath != pluginData.documentPath);
newList.push(pluginData);
this.pluginList = newList;
pluginList.set(newList);
}
// Failed to load
return [];
} catch (ex) {
Logger(`Something happened at enumerating customization :${path}`, LOG_LEVEL_NOTICE);
Logger(ex, LOG_LEVEL_VERBOSE);
}
return [];
}, { suspended: false, batchSize: 1, concurrentLimit: 10, delay: 100, yieldThreshold: 10, maintainDelay: false, totalRemainingReactiveSource: pluginScanningCount }).startPipeline();
pluginScanProcessorV2 = new QueueProcessor(async (v: AnyEntry[]) => {
const plugin = v[0]; const plugin = v[0];
const path = plugin.path || this.getPath(plugin); const path = plugin.path || this.getPath(plugin);
const oldEntry = (this.pluginList.find(e => e.documentPath == path)); const oldEntry = (this.pluginList.find(e => e.documentPath == path));
@@ -400,17 +512,220 @@ export class ConfigSync extends LiveSyncCommands {
Logger(ex, LOG_LEVEL_VERBOSE); Logger(ex, LOG_LEVEL_VERBOSE);
} }
return []; return [];
}, { suspended: false, batchSize: 1, concurrentLimit: 10, delay: 100, yieldThreshold: 10, maintainDelay: false, totalRemainingReactiveSource: pluginScanningCount }).startPipeline().root.onUpdateProgress(() => { }, { suspended: false, batchSize: 1, concurrentLimit: 10, delay: 100, yieldThreshold: 10, maintainDelay: false, totalRemainingReactiveSource: pluginScanningCount }).startPipeline();
scheduleTask("checkMissingConfigurations", 250, async () => {
if (this.pluginScanProcessor.isIdle()) {
await this.createMissingConfigurationEntry();
}
});
});
filenameToUnifiedKey(path: string, termOverRide?: string) {
const term = termOverRide || this.plugin.deviceAndVaultName;
const category = this.getFileCategory(path);
const name = (category == "CONFIG" || category == "SNIPPET") ?
(path.split("/").slice(-1)[0]) :
(category == "PLUGIN_ETC" ?
path.split("/").slice(-2).join("/") :
path.split("/").slice(-2)[0]);
return `${ICXHeader}${term}/${category}/${name}.md` as FilePathWithPrefix
}
filenameWithUnifiedKey(path: string, termOverRide?: string) {
const term = termOverRide || this.plugin.deviceAndVaultName;
const category = this.getFileCategory(path);
const name = (category == "CONFIG" || category == "SNIPPET") ?
(path.split("/").slice(-1)[0]) : path.split("/").slice(-2)[0];
const baseName = category == "CONFIG" || category == "SNIPPET" ? name : path.split("/").slice(3).join("/");
return `${ICXHeader}${term}/${category}/${name}%${baseName}` as FilePathWithPrefix;
}
unifiedKeyPrefixOfTerminal(termOverRide?: string) {
const term = termOverRide || this.plugin.deviceAndVaultName;
return `${ICXHeader}${term}/` as FilePathWithPrefix;
}
parseUnifiedPath(unifiedPath: FilePathWithPrefix): { category: string, device: string, key: string, filename: string, pathV1: FilePathWithPrefix } {
const [device, category, ...rest] = stripAllPrefixes(unifiedPath).split("/");
const relativePath = rest.join("/");
const [key, filename] = relativePath.split("%");
const pathV1 = (unifiedPath.split("%")[0] + ".md") as FilePathWithPrefix;
return { device, category, key, filename, pathV1 };
}
loadedManifest_mTime = new Map<string, number>();
async createPluginDataExFileV2(unifiedPathV2: FilePathWithPrefix, loaded?: LoadedEntry): Promise<false | LoadedEntryPluginDataExFile> {
const { category, key, filename, device } = this.parseUnifiedPath(unifiedPathV2);
if (!loaded) {
const d = await this.localDatabase.getDBEntry(unifiedPathV2);
if (!d) {
Logger(`The file ${unifiedPathV2} is not found`, LOG_LEVEL_VERBOSE);
return false;
}
if (!isLoadedEntry(d)) {
Logger(`The file ${unifiedPathV2} is not a note`, LOG_LEVEL_VERBOSE);
return false;
}
loaded = d;
}
const confKey = `${categoryToFolder(category, device)}${key}`;
const relativeFilename = `${categoryToFolder(category, "")}${(category == "CONFIG" || category == "SNIPPET") ? "" : (key + "/")}${filename}`.substring(1);
const dataSrc = getDocData(loaded.data);
const dataStart = dataSrc.indexOf(DUMMY_END);
const data = dataSrc.substring(dataStart + DUMMY_END.length);
const file: LoadedEntryPluginDataExFile = {
...loaded,
hash: "",
data: [base64ToString(data)],
filename: relativeFilename,
displayName: filename,
};
if (filename == "manifest.json") {
// Same as previously loaded
if (this.loadedManifest_mTime.get(confKey) != file.mtime && pluginManifests.get(confKey) == undefined) {
try {
const parsedManifest = JSON.parse(base64ToString(data)) as PluginManifest;
setManifest(confKey, parsedManifest);
this.pluginList.filter(e => e instanceof PluginDataExDisplayV2 && e.confKey == confKey).forEach(e => (e as PluginDataExDisplayV2).applyLoadedManifest());
pluginList.set(this.pluginList);
} catch (ex) {
Logger(`The file ${loaded.path} seems to manifest, but could not be decoded as JSON`, LOG_LEVEL_VERBOSE);
Logger(ex, LOG_LEVEL_VERBOSE);
}
this.loadedManifest_mTime.set(confKey, file.mtime);
} else {
this.pluginList.filter(e => e instanceof PluginDataExDisplayV2 && e.confKey == confKey).forEach(e => (e as PluginDataExDisplayV2).applyLoadedManifest());
pluginList.set(this.pluginList);
}
// }
}
return file;
}
createPluginDataFromV2(unifiedPathV2: FilePathWithPrefix) {
const { category, device, key, pathV1 } = this.parseUnifiedPath(unifiedPathV2);
if (category == "") return;
const ret: PluginDataExDisplayV2 = new PluginDataExDisplayV2({
documentPath: pathV1,
category: category,
name: key,
term: `${device}`,
files: [],
mtime: 0,
});
return ret;
}
updatingV2Count = 0;
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
try {
this.updatingV2Count++;
pluginV2Progress.set(this.updatingV2Count);
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
const oldEntry = this.pluginList.find(e => e.documentPath == pathV1);
let entry: PluginDataExDisplayV2 | undefined = undefined;
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
if (newEntry) {
entry = newEntry;
}
} else if (oldEntry instanceof PluginDataExDisplayV2) {
entry = oldEntry;
}
if (!entry) return;
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
if (file) {
entry.setFile(file);
} else {
entry.deleteFile(unifiedFilenameWithKey);
if (entry.files.length == 0) {
this.pluginList = this.pluginList.filter(e => e.documentPath != pathV1);
}
}
const newList = this.pluginList.filter(e => e.documentPath != entry.documentPath);
newList.push(entry);
this.pluginList = newList;
scheduleTask("updatePluginListV2", 100, () => {
pluginList.set(this.pluginList);
});
} finally {
this.updatingV2Count--;
pluginV2Progress.set(this.updatingV2Count);
}
}
async migrateV1ToV2(showMessage: boolean, entry: AnyEntry): Promise<void> {
const v1Path = entry.path;
Logger(`Migrating ${entry.path} to V2`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
if (entry.deleted) {
Logger(`The entry ${v1Path} is already deleted`, LOG_LEVEL_VERBOSE);
return;
}
if (!v1Path.endsWith(".md") && !v1Path.startsWith(ICXHeader)) {
Logger(`The entry ${v1Path} is not a customisation sync binder`, LOG_LEVEL_VERBOSE);
return
}
if (v1Path.indexOf("%") !== -1) {
Logger(`The entry ${v1Path} is already migrated`, LOG_LEVEL_VERBOSE);
return;
}
const loadedEntry = await this.localDatabase.getDBEntry(v1Path);
if (!loadedEntry) {
Logger(`The entry ${v1Path} is not found`, LOG_LEVEL_VERBOSE);
return;
}
const pluginData = deserialize(getDocDataAsArray(loadedEntry.data), {}) as PluginDataEx;
const prefixPath = v1Path.slice(0, -(".md".length)) + "%";
const category = pluginData.category;
for (const f of pluginData.files) {
const stripTable: Record<string, number> = {
"CONFIG": 0,
"THEME": 2,
"SNIPPET": 1,
"PLUGIN_MAIN": 2,
"PLUGIN_DATA": 2,
"PLUGIN_ETC": 2,
}
const deletePrefixCount = stripTable?.[category] ?? 1;
const relativeFilename = f.filename.split("/").slice(deletePrefixCount).join("/");
const v2Path = (prefixPath + relativeFilename) as FilePathWithPrefix;
// console.warn(`Migrating ${v1Path} / ${relativeFilename} to ${v2Path}`);
Logger(`Migrating ${v1Path} / ${relativeFilename} to ${v2Path}`, LOG_LEVEL_VERBOSE);
const newId = await this.plugin.path2id(v2Path);
// const buf =
const data = createBlob([DUMMY_HEAD, DUMMY_END, ...getDocDataAsArray(f.data)]);
const saving: SavingEntry = {
...loadedEntry,
_rev: undefined,
_id: newId,
path: v2Path,
data: data,
datatype: "plain",
type: "plain",
children: [],
eden: {}
}
const r = await this.plugin.localDatabase.putDBEntry(saving);
if (r && r.ok) {
Logger(`Migrated ${v1Path} / ${f.filename} to ${v2Path}`, LOG_LEVEL_INFO);
const delR = await this.deleteConfigOnDatabase(v1Path);
if (delR) {
Logger(`Deleted ${v1Path} successfully`, LOG_LEVEL_INFO);
} else {
Logger(`Failed to delete ${v1Path}`, LOG_LEVEL_NOTICE);
}
}
}
}
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> { async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
// pluginList.set([]);
if (!this.settings.usePluginSync) { if (!this.settings.usePluginSync) {
this.pluginScanProcessor.clearQueue(); this.pluginScanProcessor.clearQueue();
this.pluginList = []; this.pluginList = [];
@@ -418,60 +733,149 @@ export class ConfigSync extends LiveSyncCommands {
return; return;
} }
try { try {
this.updatingV2Count++;
pluginV2Progress.set(this.updatingV2Count);
const updatedDocumentId = updatedDocumentPath ? await this.path2id(updatedDocumentPath) : ""; const updatedDocumentId = updatedDocumentPath ? await this.path2id(updatedDocumentPath) : "";
const plugins = updatedDocumentPath ? const plugins = updatedDocumentPath ?
this.localDatabase.findEntries(updatedDocumentId, updatedDocumentId + "\u{10ffff}", { include_docs: true, key: updatedDocumentId, limit: 1 }) : this.localDatabase.findEntries(updatedDocumentId, updatedDocumentId + "\u{10ffff}", { include_docs: true, key: updatedDocumentId, limit: 1 }) :
this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true }); this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
for await (const v of plugins) { for await (const v of plugins) {
if (v.deleted || v._deleted) continue;
if (v.path.indexOf("%") !== -1) {
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
continue;
}
const path = v.path || this.getPath(v); const path = v.path || this.getPath(v);
if (updatedDocumentPath && updatedDocumentPath != path) continue; if (updatedDocumentPath && updatedDocumentPath != path) continue;
this.pluginScanProcessor.enqueue(v); this.pluginScanProcessor.enqueue(v);
} }
} finally { } finally {
pluginIsEnumerating.set(false); pluginIsEnumerating.set(false);
this.updatingV2Count--;
pluginV2Progress.set(this.updatingV2Count);
} }
pluginIsEnumerating.set(false); pluginIsEnumerating.set(false);
// return entries; // return entries;
} }
async compareUsingDisplayData(dataA: PluginDataExDisplay, dataB: PluginDataExDisplay) { async compareUsingDisplayData(dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach = false) {
const docA = await this.localDatabase.getDBEntry(dataA.documentPath); const loadFile = async (data: IPluginDataExDisplay) => {
const docB = await this.localDatabase.getDBEntry(dataB.documentPath); if (data instanceof PluginDataExDisplayV2 || compareEach) {
return data.files[0] as LoadedEntryPluginDataExFile;
if (docA && docB) { }
const pluginDataA = deserialize(getDocDataAsArray(docA.data), {}) as PluginDataEx; const loadDoc = await this.localDatabase.getDBEntry(data.documentPath);
pluginDataA.documentPath = dataA.documentPath; if (!loadDoc) return false;
const pluginDataB = deserialize(getDocDataAsArray(docB.data), {}) as PluginDataEx; const pluginData = deserialize(getDocDataAsArray(loadDoc.data), {}) as PluginDataEx;
pluginDataB.documentPath = dataB.documentPath; pluginData.documentPath = data.documentPath;
const file = pluginData.files[0];
// Use outer structure to wrap each data. const doc = { ...loadDoc, ...file, datatype: "newnote" } as LoadedEntryPluginDataExFile;
return await this.showJSONMergeDialogAndMerge(docA, docB, pluginDataA, pluginDataB); return doc;
}
const fileA = await loadFile(dataA);
const fileB = await loadFile(dataB);
Logger(`Comparing: ${dataA.documentPath} <-> ${dataB.documentPath}`, LOG_LEVEL_VERBOSE);
if (!fileA || !fileB) {
Logger(`Could not load ${dataA.name} for comparison: ${!fileA ? dataA.term : ""}${!fileB ? dataB.term : ""}`, LOG_LEVEL_NOTICE);
return false;
}
let path = stripAllPrefixes(fileA.path.split("/").slice(-1).join("/") as FilePath); // TODO:adjust
if (path.indexOf("%") !== -1) {
path = path.split("%")[1] as FilePath;
}
if (fileA.path.endsWith(".json")) {
return serialized("config:merge-data", () => new Promise<boolean>((res) => {
Logger("Opening data-merging dialog", LOG_LEVEL_VERBOSE);
// const docs = [docA, docB];
const modal = new JsonResolveModal(this.app, path, [fileA, fileB], async (keep, result) => {
if (result == null) return res(false);
try {
res(await this.applyData(dataA, result));
} catch (ex) {
Logger("Could not apply merged file");
Logger(ex, LOG_LEVEL_VERBOSE);
res(false);
}
}, "Local", `${dataB.term}`, "B", true, true, "Difference between local and remote");
modal.open();
}));
} else {
const dmp = new diff_match_patch();
let docAData = getDocData(fileA.data);
let docBData = getDocData(fileB.data);
if (fileA?.datatype != "plain") {
docAData = base64ToString(docAData);
}
if (fileB?.datatype != "plain") {
docBData = base64ToString(docBData);
}
const diffMap = dmp.diff_linesToChars_(docAData, docBData);
const diff = dmp.diff_main(diffMap.chars1, diffMap.chars2, false);
dmp.diff_charsToLines_(diff, diffMap.lineArray);
dmp.diff_cleanupSemantic(diff);
const diffResult: diff_result = {
left: { rev: "A", ...fileA, data: docAData },
right: { rev: "B", ...fileB, data: docBData },
diff: diff
}
console.dir(diffResult);
const d = new ConflictResolveModal(this.app, path, diffResult, true, dataB.term);
d.open();
const ret = await d.waitForResult();
if (ret === CANCELLED) return false;
if (ret === LEAVE_TO_SUBSEQUENT) return false;
const resultContent = ret == "A" ? docAData : ret == "B" ? docBData : undefined;
if (resultContent) {
return await this.applyData(dataA, resultContent);
}
return false;
} }
return false;
} }
showJSONMergeDialogAndMerge(docA: LoadedEntry, docB: LoadedEntry, pluginDataA: PluginDataEx, pluginDataB: PluginDataEx): Promise<boolean> { async applyDataV2(data: PluginDataExDisplayV2, content?: string): Promise<boolean> {
const fileA = { ...pluginDataA.files[0], ctime: pluginDataA.files[0].mtime, _id: `${pluginDataA.documentPath}` as DocumentID }; const baseDir = this.app.vault.configDir;
const fileB = pluginDataB.files[0]; try {
const docAx = { ...docA, ...fileA, datatype: "newnote" } as LoadedEntry, docBx = { ...docB, ...fileB, datatype: "newnote" } as LoadedEntry if (content) {
return serialized("config:merge-data", () => new Promise((res) => { // const dt = createBlob(content);
Logger("Opening data-merging dialog", LOG_LEVEL_VERBOSE); const filename = data.files[0].filename;
// const docs = [docA, docB]; Logger(`Applying ${filename} of ${data.displayName || data.name}..`);
const path = stripAllPrefixes(docAx.path.split("/").slice(-1).join("/") as FilePath); const path = `${baseDir}/${filename}` as FilePath;
const modal = new JsonResolveModal(this.app, path, [docAx, docBx], async (keep, result) => { await this.vaultAccess.ensureDirectory(path);
if (result == null) return res(false); await this.vaultAccess.adapterWrite(path, content);
try { await this.storeCustomisationFileV2(path, this.plugin.deviceAndVaultName);
res(await this.applyData(pluginDataA, result));
} catch (ex) { } else {
Logger("Could not apply merged file"); const files = data.files;
Logger(ex, LOG_LEVEL_VERBOSE); for (const f of files) {
res(false); const path = `${baseDir}/${f.filename}` as FilePath;
Logger(`Applying ${f.filename} of ${data.displayName || data.name}..`);
// const contentEach = createBlob(f.data);
this.vaultAccess.ensureDirectory(path);
if (f.datatype == "newnote") {
const content = base64ToArrayBuffer(f.data);
await this.vaultAccess.adapterWrite(path, content);
} else {
const content = getDocData(f.data);
await this.vaultAccess.adapterWrite(path, content);
}
Logger(`Applied ${f.filename} of ${data.displayName || data.name}..`);
await this.storeCustomisationFileV2(path, this.plugin.deviceAndVaultName);
} }
}, "📡", "🛰️", "B"); }
modal.open(); } catch (ex) {
})); Logger(`Applying ${data.displayName || data.name}.. Failed`, LOG_LEVEL_NOTICE);
Logger(ex, LOG_LEVEL_VERBOSE);
return false;
}
return true;
} }
async applyData(data: PluginDataEx, content?: string): Promise<boolean> { async applyData(data: IPluginDataExDisplay, content?: string): Promise<boolean> {
Logger(`Applying ${data.displayName || data.name}..`); Logger(`Applying ${data.displayName || data.name
}..`);
if (data instanceof PluginDataExDisplayV2) {
return this.applyDataV2(data, content);
}
const baseDir = this.app.vault.configDir; const baseDir = this.app.vault.configDir;
try { try {
if (!data.documentPath) throw "InternalError: Document path not exist"; if (!data.documentPath) throw "InternalError: Document path not exist";
@@ -532,9 +936,22 @@ export class ConfigSync extends LiveSyncCommands {
async deleteData(data: PluginDataEx): Promise<boolean> { async deleteData(data: PluginDataEx): Promise<boolean> {
try { try {
if (data.documentPath) { if (data.documentPath) {
await this.deleteConfigOnDatabase(data.documentPath); const delList = [];
await this.updatePluginList(false, data.documentPath); if (this.useV2) {
Logger(`Delete: ${data.documentPath}`, LOG_LEVEL_NOTICE); const deleteList = this.pluginList.filter(e => e.documentPath == data.documentPath).filter(e => e instanceof PluginDataExDisplayV2).map(e => e.files).flat();
for (const e of deleteList) {
delList.push(e.path);
}
}
delList.push(data.documentPath);
const p = delList.map(async e => {
await this.deleteConfigOnDatabase(e);
await this.updatePluginList(false, e)
});
await Promise.allSettled(p);
// await this.deleteConfigOnDatabase(data.documentPath);
// await this.updatePluginList(false, data.documentPath);
Logger(`Deleted: ${data.category}/${data.name} of ${data.category} (${delList.length} items)`, LOG_LEVEL_NOTICE);
} }
return true; return true;
} catch (ex) { } catch (ex) {
@@ -645,15 +1062,65 @@ export class ConfigSync extends LiveSyncCommands {
} }
} }
filenameToUnifiedKey(path: string, termOverRide?: string) {
const term = termOverRide || this.plugin.deviceAndVaultName; async storeCustomisationFileV2(path: FilePath, term: string, saveRelatives = false) {
const category = this.getFileCategory(path); const vf = this.filenameWithUnifiedKey(path, term);
const name = (category == "CONFIG" || category == "SNIPPET") ? return await serialized(`plugin-${vf}`, async () => {
(path.split("/").slice(-1)[0]) : const prefixedFileName = vf;
(category == "PLUGIN_ETC" ?
path.split("/").slice(-2).join("/") : const id = await this.path2id(prefixedFileName);
path.split("/").slice(-2)[0]); const stat = await this.vaultAccess.adapterStat(path);
return `${ICXHeader}${term}/${category}/${name}.md` as FilePathWithPrefix if (!stat) {
return false;
}
const mtime = stat.mtime;
const content = await this.vaultAccess.adapterReadBinary(path);
const contentBlob = createBlob([DUMMY_HEAD, DUMMY_END, ...await arrayBufferToBase64(content)]);
// const contentBlob = createBlob(content);
try {
const old = await this.localDatabase.getDBEntryMeta(prefixedFileName, undefined, false);
let saveData: SavingEntry;
if (old === false) {
saveData = {
_id: id,
path: prefixedFileName,
data: contentBlob,
mtime,
ctime: mtime,
datatype: "plain",
size: contentBlob.size,
children: [],
deleted: false,
type: "plain",
eden: {}
};
} else {
if (old.mtime == mtime) {
// Logger(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
return true;
}
saveData =
{
...old,
data: contentBlob,
mtime,
size: contentBlob.size,
datatype: "plain",
children: [],
deleted: false,
type: "plain",
};
}
const ret = await this.localDatabase.putDBEntry(saveData);
Logger(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
return ret;
} catch (ex) {
Logger(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
Logger(ex, LOG_LEVEL_VERBOSE);
return false;
}
})
} }
async storeCustomizationFiles(path: FilePath, termOverRide?: string) { async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
const term = termOverRide || this.plugin.deviceAndVaultName; const term = termOverRide || this.plugin.deviceAndVaultName;
@@ -661,7 +1128,13 @@ export class ConfigSync extends LiveSyncCommands {
Logger("We have to configure the device name", LOG_LEVEL_NOTICE); Logger("We have to configure the device name", LOG_LEVEL_NOTICE);
return; return;
} }
if (this.useV2) {
return await this.storeCustomisationFileV2(path, term);
}
const vf = this.filenameToUnifiedKey(path, term); const vf = this.filenameToUnifiedKey(path, term);
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
return await serialized(`plugin-${vf}`, async () => { return await serialized(`plugin-${vf}`, async () => {
const category = this.getFileCategory(path); const category = this.getFileCategory(path);
let mtime = 0; let mtime = 0;
@@ -787,7 +1260,9 @@ export class ConfigSync extends LiveSyncCommands {
return false; return false;
const configDir = normalizePath(this.app.vault.configDir); const configDir = normalizePath(this.app.vault.configDir);
const synchronisedInConfigSync = Object.values(this.settings.pluginSyncExtendedSetting).filter(e => e.mode != MODE_SELECTIVE).map(e => e.files).flat().map(e => `${configDir}/${e}`.toLowerCase()); const synchronisedInConfigSync = Object.values(this.settings.pluginSyncExtendedSetting).filter(e =>
e.mode != MODE_SELECTIVE && e.mode != MODE_SHINY
).map(e => e.files).flat().map(e => `${configDir}/${e}`.toLowerCase());
if (synchronisedInConfigSync.some(e => e.startsWith(path.toLowerCase()))) { if (synchronisedInConfigSync.some(e => e.startsWith(path.toLowerCase()))) {
Logger(`Customization file skipped: ${path}`, LOG_LEVEL_VERBOSE); Logger(`Customization file skipped: ${path}`, LOG_LEVEL_VERBOSE);
return; return;
@@ -807,6 +1282,8 @@ export class ConfigSync extends LiveSyncCommands {
} }
async scanAllConfigFiles(showMessage: boolean) { async scanAllConfigFiles(showMessage: boolean) {
await shareRunningResult("scanAllConfigFiles", async () => { await shareRunningResult("scanAllConfigFiles", async () => {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
@@ -817,40 +1294,94 @@ export class ConfigSync extends LiveSyncCommands {
return; return;
} }
const filesAll = await this.scanInternalFiles(); const filesAll = await this.scanInternalFiles();
const files = filesAll.filter(e => this.isTargetPath(e)).map(e => ({ key: this.filenameToUnifiedKey(e), file: e })); if (this.useV2) {
const virtualPathsOfLocalFiles = [...new Set(files.map(e => e.key))]; const filesAllUnified = filesAll.filter(e => this.isTargetPath(e)).map(e => [this.filenameWithUnifiedKey(e, term), e] as [FilePathWithPrefix, FilePath]);
const filesOnDB = ((await this.localDatabase.allDocsRaw({ startkey: ICXHeader + "", endkey: `${ICXHeader}\u{10ffff}`, include_docs: true })).rows.map(e => e.doc) as InternalFileEntry[]).filter(e => !e.deleted); const localFileMap = new Map(filesAllUnified.map(e => [e[0], e[1]]));
let deleteCandidate = filesOnDB.map(e => this.getPath(e)).filter(e => e.startsWith(`${ICXHeader}${term}/`)); const prefix = this.unifiedKeyPrefixOfTerminal(term);
for (const vp of virtualPathsOfLocalFiles) { const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, { include_docs: true });
const p = files.find(e => e.key == vp)?.file; const tasks = [] as (() => Promise<void>)[];
if (!p) { const concurrency = 10;
Logger(`scanAllConfigFiles - File not found: ${vp}`, LOG_LEVEL_VERBOSE); const semaphore = Semaphore(concurrency);
continue; for await (const item of entries) {
if (item.path.indexOf("%") !== -1) {
continue;
}
tasks.push(async () => {
const releaser = await semaphore.acquire();
try {
const unifiedFilenameWithKey = `${item._id}` as FilePathWithPrefix;
const localPath = localFileMap.get(unifiedFilenameWithKey);
if (localPath) {
await this.storeCustomisationFileV2(localPath, term);
localFileMap.delete(unifiedFilenameWithKey);
} else {
await this.deleteConfigOnDatabase(unifiedFilenameWithKey);
}
} catch (ex) {
Logger(`scanAllConfigFiles - Error: ${item._id}`, LOG_LEVEL_VERBOSE);
Logger(ex, LOG_LEVEL_VERBOSE);
} finally {
releaser();
}
})
} }
await this.storeCustomizationFiles(p); await Promise.all(tasks.map(e => e()));
deleteCandidate = deleteCandidate.filter(e => e != vp); // Extra files
const taskExtra = [] as (() => Promise<void>)[];
for (const [, filePath] of localFileMap) {
taskExtra.push(async () => {
const releaser = await semaphore.acquire();
try {
await this.storeCustomisationFileV2(filePath, term);
} catch (ex) {
Logger(`scanAllConfigFiles - Error: ${filePath}`, LOG_LEVEL_VERBOSE);
Logger(ex, LOG_LEVEL_VERBOSE);
}
finally {
releaser();
}
})
}
await Promise.all(taskExtra.map(e => e()));
this.updatePluginList(false).then(/* fire and forget */);
} else {
const files = filesAll.filter(e => this.isTargetPath(e)).map(e => ({ key: this.filenameToUnifiedKey(e), file: e }));
const virtualPathsOfLocalFiles = [...new Set(files.map(e => e.key))];
const filesOnDB = ((await this.localDatabase.allDocsRaw({ startkey: ICXHeader + "", endkey: `${ICXHeader}\u{10ffff}`, include_docs: true })).rows.map(e => e.doc) as InternalFileEntry[]).filter(e => !e.deleted);
let deleteCandidate = filesOnDB.map(e => this.getPath(e)).filter(e => e.startsWith(`${ICXHeader}${term}/`));
for (const vp of virtualPathsOfLocalFiles) {
const p = files.find(e => e.key == vp)?.file;
if (!p) {
Logger(`scanAllConfigFiles - File not found: ${vp}`, LOG_LEVEL_VERBOSE);
continue;
}
await this.storeCustomizationFiles(p);
deleteCandidate = deleteCandidate.filter(e => e != vp);
}
for (const vp of deleteCandidate) {
await this.deleteConfigOnDatabase(vp);
}
this.updatePluginList(false).then(/* fire and forget */);
} }
for (const vp of deleteCandidate) {
await this.deleteConfigOnDatabase(vp);
}
this.updatePluginList(false).then(/* fire and forget */);
}); });
} }
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) { async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
// const id = await this.path2id(prefixedFileName); // const id = await this.path2id(prefixedFileName);
const mtime = new Date().getTime(); const mtime = new Date().getTime();
await serialized("file-x-" + prefixedFileName, async () => { return await serialized("file-x-" + prefixedFileName, async () => {
try { try {
const old = await this.localDatabase.getDBEntryMeta(prefixedFileName, undefined, false) as InternalFileEntry | false; const old = await this.localDatabase.getDBEntryMeta(prefixedFileName, undefined, false) as InternalFileEntry | false;
let saveData: InternalFileEntry; let saveData: InternalFileEntry;
if (old === false) { if (old === false) {
Logger(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted (Not found on database)`); Logger(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted (Not found on database)`);
return; return true;
} else { } else {
if (old.deleted) { if (old.deleted) {
Logger(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted`); Logger(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted`);
return; return true;
} }
saveData = saveData =
{ {
@@ -865,6 +1396,7 @@ export class ConfigSync extends LiveSyncCommands {
await this.localDatabase.putRaw(saveData); await this.localDatabase.putRaw(saveData);
await this.updatePluginList(false, prefixedFileName); await this.updatePluginList(false, prefixedFileName);
Logger(`STORAGE -x> DB:${prefixedFileName}: (config) Done`); Logger(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
return true;
} catch (ex) { } catch (ex) {
Logger(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`); Logger(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
Logger(ex, LOG_LEVEL_VERBOSE); Logger(ex, LOG_LEVEL_VERBOSE);

Submodule src/lib updated: f05b631841...f0253a8548

View File

@@ -2417,6 +2417,10 @@ Or if you are sure know what had been happened, we can unlock the database from
count++; count++;
if (count % 25 == 0) Logger(`Collecting local files on the DB: ${count}`, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll"); if (count % 25 == 0) Logger(`Collecting local files on the DB: ${count}`, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll");
const path = getPath(doc); const path = getPath(doc);
// const docPath = doc.path;
// if (path != docPath) {
// debugger;
// }
if (isValidPath(path) && await this.isTargetFile(path)) { if (isValidPath(path) && await this.isTargetFile(path)) {
filesDatabase.push(path); filesDatabase.push(path);
} }
@@ -2513,14 +2517,20 @@ Or if you are sure know what had been happened, we can unlock the database from
const syncFilesToSync = pairs.map((e) => ({ file: e.file, doc: docsMap[e.id] as LoadedEntry })); const syncFilesToSync = pairs.map((e) => ({ file: e.file, doc: docsMap[e.id] as LoadedEntry }));
return syncFilesToSync; return syncFilesToSync;
} }
, { batchSize: 10, concurrentLimit: 5, delay: 10, suspended: false })) , { batchSize: 100, concurrentLimit: 1, delay: 10, suspended: false, maintainDelay: true, yieldThreshold: 100 }))
.pipeTo( .pipeTo(
new QueueProcessor( new QueueProcessor(
async (loadedPairs) => { async (loadedPairs) => {
const e = loadedPairs[0]; for (const pair of loadedPairs)
await this.syncFileBetweenDBandStorage(e.file, e.doc); try {
const e = pair;
await this.syncFileBetweenDBandStorage(e.file, e.doc);
} catch (ex) {
Logger("Error while syncFileBetweenDBandStorage", LOG_LEVEL_NOTICE);
Logger(ex, LOG_LEVEL_VERBOSE);
}
return; return;
}, { batchSize: 1, concurrentLimit: 5, delay: 10, suspended: false } }, { batchSize: 5, concurrentLimit: 10, delay: 10, suspended: false, yieldThreshold: 10, maintainDelay: true }
)) ))
const allSyncFiles = syncFiles.length; const allSyncFiles = syncFiles.length;

View File

@@ -13,10 +13,22 @@ export class ConflictResolveModal extends Modal {
isClosed = false; isClosed = false;
consumed = false; consumed = false;
constructor(app: App, filename: string, diff: diff_result) { title: string = "Conflicting changes";
pluginPickMode: boolean = false;
localName: string = "Keep A";
remoteName: string = "Keep B";
constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string) {
super(app); super(app);
this.result = diff; this.result = diff;
this.filename = filename; this.filename = filename;
this.pluginPickMode = pluginPickMode || false;
if (this.pluginPickMode) {
this.title = "Pick a version";
this.remoteName = `Use ${remoteName || "Remote"}`;
this.localName = "Use Local"
}
// Send cancel signal for the previous merge dialogue // Send cancel signal for the previous merge dialogue
// if not there, simply be ignored. // if not there, simply be ignored.
// sendValue("close-resolve-conflict:" + this.filename, false); // sendValue("close-resolve-conflict:" + this.filename, false);
@@ -36,7 +48,7 @@ export class ConflictResolveModal extends Modal {
} }
}, 10) }, 10)
// sendValue("close-resolve-conflict:" + this.filename, false); // sendValue("close-resolve-conflict:" + this.filename, false);
this.titleEl.setText("Conflicting changes"); this.titleEl.setText(this.title);
contentEl.empty(); contentEl.empty();
contentEl.createEl("span", { text: this.filename }); contentEl.createEl("span", { text: this.filename });
const div = contentEl.createDiv(""); const div = contentEl.createDiv("");
@@ -62,10 +74,12 @@ export class ConflictResolveModal extends Modal {
div2.innerHTML = ` div2.innerHTML = `
<span class='deleted'>A:${date1}</span><br /><span class='added'>B:${date2}</span><br> <span class='deleted'>A:${date1}</span><br /><span class='added'>B:${date2}</span><br>
`; `;
contentEl.createEl("button", { text: "Keep A" }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.right.rev))); contentEl.createEl("button", { text: this.localName }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.right.rev))).style.marginRight = "4px";
contentEl.createEl("button", { text: "Keep B" }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.left.rev))); contentEl.createEl("button", { text: this.remoteName }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.left.rev))).style.marginRight = "4px";
contentEl.createEl("button", { text: "Concat both" }, (e) => e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT))); if (!this.pluginPickMode) {
contentEl.createEl("button", { text: "Not now" }, (e) => e.addEventListener("click", () => this.sendResponse(CANCELLED))); contentEl.createEl("button", { text: "Concat both" }, (e) => e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT))).style.marginRight = "4px";
}
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => e.addEventListener("click", () => this.sendResponse(CANCELLED))).style.marginRight = "4px";
} }
sendResponse(result: MergeDialogResult) { sendResponse(result: MergeDialogResult) {

View File

@@ -12,15 +12,24 @@ export class JsonResolveModal extends Modal {
nameA: string; nameA: string;
nameB: string; nameB: string;
defaultSelect: string; defaultSelect: string;
keepOrder: boolean;
hideLocal: boolean;
title: string = "Conflicted Setting";
constructor(app: App, filename: FilePath, docs: LoadedEntry[], callback: (keepRev?: string, mergedStr?: string) => Promise<void>, nameA?: string, nameB?: string, defaultSelect?: string) { constructor(app: App, filename: FilePath,
docs: LoadedEntry[], callback: (keepRev?: string, mergedStr?: string) => Promise<void>,
nameA?: string, nameB?: string, defaultSelect?: string,
keepOrder?: boolean, hideLocal?: boolean, title: string = "Conflicted Setting") {
super(app); super(app);
this.callback = callback; this.callback = callback;
this.filename = filename; this.filename = filename;
this.docs = docs; this.docs = docs;
this.nameA = nameA || ""; this.nameA = nameA || "";
this.nameB = nameB || ""; this.nameB = nameB || "";
this.keepOrder = keepOrder || false;
this.defaultSelect = defaultSelect || ""; this.defaultSelect = defaultSelect || "";
this.title = title;
this.hideLocal = hideLocal ?? false;
waitForSignal(`cancel-internal-conflict:${filename}`).then(() => this.close()); waitForSignal(`cancel-internal-conflict:${filename}`).then(() => this.close());
} }
async UICallback(keepRev?: string, mergedStr?: string) { async UICallback(keepRev?: string, mergedStr?: string) {
@@ -31,7 +40,7 @@ export class JsonResolveModal extends Modal {
onOpen() { onOpen() {
const { contentEl } = this; const { contentEl } = this;
this.titleEl.setText("Conflicted Setting"); this.titleEl.setText(this.title);
contentEl.empty(); contentEl.empty();
if (this.component == undefined) { if (this.component == undefined) {
@@ -43,6 +52,8 @@ export class JsonResolveModal extends Modal {
nameA: this.nameA, nameA: this.nameA,
nameB: this.nameB, nameB: this.nameB,
defaultSelect: this.defaultSelect, defaultSelect: this.defaultSelect,
keepOrder: this.keepOrder,
hideLocal: this.hideLocal,
callback: (keepRev: string | undefined, mergedStr: string | undefined) => this.UICallback(keepRev, mergedStr), callback: (keepRev: string | undefined, mergedStr: string | undefined) => this.UICallback(keepRev, mergedStr),
}, },
}); });

View File

@@ -13,6 +13,8 @@
export let nameA: string = "A"; export let nameA: string = "A";
export let nameB: string = "B"; export let nameB: string = "B";
export let defaultSelect: string = ""; export let defaultSelect: string = "";
export let keepOrder = false;
export let hideLocal: boolean = false;
let docA: LoadedEntry; let docA: LoadedEntry;
let docB: LoadedEntry; let docB: LoadedEntry;
let docAContent = ""; let docAContent = "";
@@ -55,9 +57,12 @@
if (mode == "AB") return callback(undefined, JSON.stringify(objAB, null, 2)); if (mode == "AB") return callback(undefined, JSON.stringify(objAB, null, 2));
callback(undefined, undefined); callback(undefined, undefined);
} }
function cancel() {
callback(undefined, undefined);
}
$: { $: {
if (docs && docs.length >= 1) { if (docs && docs.length >= 1) {
if (docs[0].mtime < docs[1].mtime) { if (keepOrder || docs[0].mtime < docs[1].mtime) {
docA = docs[0]; docA = docs[0];
docB = docs[1]; docB = docs[1];
} else { } else {
@@ -96,13 +101,19 @@
diffs = getJsonDiff(objA, selectedObj); diffs = getJsonDiff(objA, selectedObj);
} }
$: modes = [ let modes = [] as ["" | "A" | "B" | "AB" | "BA", string][];
["", "Not now"], $: {
["A", nameA || "A"], let newModes = [] as typeof modes;
["B", nameB || "B"],
["AB", `${nameA || "A"} + ${nameB || "B"}`], if (!hideLocal) {
["BA", `${nameB || "B"} + ${nameA || "A"}`], newModes.push(["", "Not now"]);
] as ["" | "A" | "B" | "AB" | "BA", string][]; newModes.push(["A", nameA || "A"]);
}
newModes.push(["B", nameB || "B"]);
newModes.push(["AB", `${nameA || "A"} + ${nameB || "B"}`]);
newModes.push(["BA", `${nameB || "B"} + ${nameA || "A"}`]);
modes = newModes;
}
</script> </script>
<h2>{filename}</h2> <h2>{filename}</h2>
@@ -132,28 +143,54 @@
{:else} {:else}
NO PREVIEW NO PREVIEW
{/if} {/if}
<div>
{nameA}
{#if docA._id == docB._id}
Rev:{revStringToRevNumber(docA._rev)}
{/if} ,{new Date(docA.mtime).toLocaleString()}
{docAContent.length} letters
</div>
<div> <div class="infos">
{nameB} <table>
{#if docA._id == docB._id} <tr>
Rev:{revStringToRevNumber(docB._rev)} <th>{nameA}</th>
{/if} ,{new Date(docB.mtime).toLocaleString()} <td
{docBContent.length} letters >{#if docA._id == docB._id}
Rev:{revStringToRevNumber(docA._rev)}
{/if}
{new Date(docA.mtime).toLocaleString()}</td
>
<td>
{docAContent.length} letters
</td>
</tr>
<tr>
<th>{nameB}</th>
<td
>{#if docA._id == docB._id}
Rev:{revStringToRevNumber(docB._rev)}
{/if}
{new Date(docB.mtime).toLocaleString()}</td
>
<td>
{docBContent.length} letters
</td>
</tr>
</table>
</div> </div>
<div class="buttons"> <div class="buttons">
{#if hideLocal}
<button on:click={cancel}>Cancel</button>
{/if}
<button on:click={apply}>Apply</button> <button on:click={apply}>Apply</button>
</div> </div>
{/if} {/if}
<style> <style>
.spacer {
flex-grow: 1;
}
.infos {
display: flex;
justify-content: space-between;
margin: 4px 0.5em;
}
.deleted { .deleted {
text-decoration: line-through; text-decoration: line-through;
} }

View File

@@ -2231,7 +2231,7 @@ ${stringifyYaml(pluginConfig)}`;
// With great respect, thank you TfTHacker! // With great respect, thank you TfTHacker!
// Refer: https://github.com/TfTHacker/obsidian42-brat/blob/main/src/features/BetaPlugins.ts // Refer: https://github.com/TfTHacker/obsidian42-brat/blob/main/src/features/BetaPlugins.ts
const containerPluginSettings = containerEl.createDiv(); const containerPluginSettings = containerEl.createDiv();
this.createEl(containerPluginSettings, "h3", { text: "Customization sync (beta)" }); this.createEl(containerPluginSettings, "h3", { text: "Customization sync (beta 3)" });
const enableOnlyOnPluginSyncIsNotEnabled = enableOnly(() => this.isConfiguredAs("usePluginSync", false)); const enableOnlyOnPluginSyncIsNotEnabled = enableOnly(() => this.isConfiguredAs("usePluginSync", false));
const visibleOnlyOnPluginSyncEnabled = visibleOnly(() => this.isConfiguredAs("usePluginSync", true)); const visibleOnlyOnPluginSyncEnabled = visibleOnly(() => this.isConfiguredAs("usePluginSync", true));
@@ -2242,6 +2242,9 @@ ${stringifyYaml(pluginConfig)}`;
onUpdate: enableOnlyOnPluginSyncIsNotEnabled onUpdate: enableOnlyOnPluginSyncIsNotEnabled
}); });
new Setting(containerPluginSettings)
.autoWireToggle("usePluginSyncV2")
new Setting(containerPluginSettings) new Setting(containerPluginSettings)
.autoWireToggle("usePluginSync", { .autoWireToggle("usePluginSync", {
onUpdate: enableOnly(() => !this.isConfiguredAs("deviceAndVaultName", "")) onUpdate: enableOnly(() => !this.isConfiguredAs("deviceAndVaultName", ""))

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import ObsidianLiveSyncPlugin from "../main"; import ObsidianLiveSyncPlugin from "../main";
import { type PluginDataExDisplay, pluginIsEnumerating, pluginList } from "../features/CmdConfigSync"; import { type IPluginDataExDisplay, pluginIsEnumerating, pluginList, pluginManifestStore, pluginV2Progress } from "../features/CmdConfigSync";
import PluginCombo from "./components/PluginCombo.svelte"; import PluginCombo from "./components/PluginCombo.svelte";
import { Menu } from "obsidian"; import { Menu, type PluginManifest } from "obsidian";
import { unique } from "../lib/src/common/utils"; import { unique } from "../lib/src/common/utils";
import { MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, type SYNC_MODE, type PluginSyncSettingEntry } from "../lib/src/common/types"; import { MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, type SYNC_MODE, type PluginSyncSettingEntry, MODE_SHINY } from "../lib/src/common/types";
import { normalizePath } from "../deps"; import { normalizePath } from "../deps";
export let plugin: ObsidianLiveSyncPlugin; export let plugin: ObsidianLiveSyncPlugin;
@@ -14,9 +14,10 @@
const addOn = plugin.addOnConfigSync; const addOn = plugin.addOnConfigSync;
let list: PluginDataExDisplay[] = []; let list: IPluginDataExDisplay[] = [];
let selectNewestPulse = 0; let selectNewestPulse = 0;
let selectNewestStyle = 0;
let hideEven = false; let hideEven = false;
let loading = false; let loading = false;
let applyAllPluse = 0; let applyAllPluse = 0;
@@ -39,13 +40,13 @@
requestUpdate(); requestUpdate();
}); });
function filterList(list: PluginDataExDisplay[], categories: string[]) { function filterList(list: IPluginDataExDisplay[], categories: string[]) {
const w = list.filter((e) => categories.indexOf(e.category) !== -1); const w = list.filter((e) => categories.indexOf(e.category) !== -1);
return w.sort((a, b) => `${a.category}-${a.name}`.localeCompare(`${b.category}-${b.name}`)); return w.sort((a, b) => `${a.category}-${a.name}`.localeCompare(`${b.category}-${b.name}`));
} }
function groupBy(items: PluginDataExDisplay[], key: string) { function groupBy(items: IPluginDataExDisplay[], key: string) {
let ret = {} as Record<string, PluginDataExDisplay[]>; let ret = {} as Record<string, IPluginDataExDisplay[]>;
for (const v of items) { for (const v of items) {
//@ts-ignore //@ts-ignore
const k = (key in v ? v[key] : "") as string; const k = (key in v ? v[key] : "") as string;
@@ -71,19 +72,24 @@
async function replicate() { async function replicate() {
await plugin.replicate(true); await plugin.replicate(true);
} }
function selectAllNewest() { function selectAllNewest(selectMode: boolean) {
selectNewestPulse++; selectNewestPulse++;
selectNewestStyle = selectMode ? 1 : 2;
}
function resetSelectNewest() {
selectNewestPulse++;
selectNewestStyle = 3;
} }
function applyAll() { function applyAll() {
applyAllPluse++; applyAllPluse++;
} }
async function applyData(data: PluginDataExDisplay): Promise<boolean> { async function applyData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.applyData(data); return await addOn.applyData(data);
} }
async function compareData(docA: PluginDataExDisplay, docB: PluginDataExDisplay): Promise<boolean> { async function compareData(docA: IPluginDataExDisplay, docB: IPluginDataExDisplay, compareEach = false): Promise<boolean> {
return await addOn.compareUsingDisplayData(docA, docB); return await addOn.compareUsingDisplayData(docA, docB, compareEach);
} }
async function deleteData(data: PluginDataExDisplay): Promise<boolean> { async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.deleteData(data); return await addOn.deleteData(data);
} }
function askMode(evt: MouseEvent, title: string, key: string) { function askMode(evt: MouseEvent, title: string, key: string) {
@@ -91,7 +97,7 @@
menu.addItem((item) => item.setTitle(title).setIsLabel(true)); menu.addItem((item) => item.setTitle(title).setIsLabel(true));
menu.addSeparator(); menu.addSeparator();
const prevMode = automaticList.get(key) ?? MODE_SELECTIVE; const prevMode = automaticList.get(key) ?? MODE_SELECTIVE;
for (const mode of [MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED]) { for (const mode of [MODE_SELECTIVE, MODE_AUTOMATIC, MODE_PAUSED, MODE_SHINY]) {
menu.addItem((item) => { menu.addItem((item) => {
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`) item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
.onClick((e) => { .onClick((e) => {
@@ -139,6 +145,7 @@
thisTerm, thisTerm,
hideNotApplicable, hideNotApplicable,
selectNewest: selectNewestPulse, selectNewest: selectNewestPulse,
selectNewestStyle,
applyAllPluse, applyAllPluse,
applyData, applyData,
compareData, compareData,
@@ -150,24 +157,29 @@
const ICON_EMOJI_PAUSED = `⛔`; const ICON_EMOJI_PAUSED = `⛔`;
const ICON_EMOJI_AUTOMATIC = `✨`; const ICON_EMOJI_AUTOMATIC = `✨`;
const ICON_EMOJI_SELECTIVE = `🔀`; const ICON_EMOJI_SELECTIVE = `🔀`;
const ICON_EMOJI_FLAGGED = `🚩`;
const ICONS: { [key: number]: string } = { const ICONS: { [key: number]: string } = {
[MODE_SELECTIVE]: ICON_EMOJI_SELECTIVE, [MODE_SELECTIVE]: ICON_EMOJI_SELECTIVE,
[MODE_PAUSED]: ICON_EMOJI_PAUSED, [MODE_PAUSED]: ICON_EMOJI_PAUSED,
[MODE_AUTOMATIC]: ICON_EMOJI_AUTOMATIC, [MODE_AUTOMATIC]: ICON_EMOJI_AUTOMATIC,
[MODE_SHINY]: ICON_EMOJI_FLAGGED,
}; };
const TITLES: { [key: number]: string } = { const TITLES: { [key: number]: string } = {
[MODE_SELECTIVE]: "Selective", [MODE_SELECTIVE]: "Selective",
[MODE_PAUSED]: "Ignore", [MODE_PAUSED]: "Ignore",
[MODE_AUTOMATIC]: "Automatic", [MODE_AUTOMATIC]: "Automatic",
[MODE_SHINY]: "Flagged Selective",
}; };
const PREFIX_PLUGIN_ALL = "PLUGIN_ALL"; const PREFIX_PLUGIN_ALL = "PLUGIN_ALL";
const PREFIX_PLUGIN_DATA = "PLUGIN_DATA"; const PREFIX_PLUGIN_DATA = "PLUGIN_DATA";
const PREFIX_PLUGIN_MAIN = "PLUGIN_MAIN"; const PREFIX_PLUGIN_MAIN = "PLUGIN_MAIN";
const PREFIX_PLUGIN_ETC = "PLUGIN_ETC";
function setMode(key: string, mode: SYNC_MODE) { function setMode(key: string, mode: SYNC_MODE) {
if (key.startsWith(PREFIX_PLUGIN_ALL + "/")) { if (key.startsWith(PREFIX_PLUGIN_ALL + "/")) {
setMode(PREFIX_PLUGIN_DATA + key.substring(PREFIX_PLUGIN_ALL.length), mode); setMode(PREFIX_PLUGIN_DATA + key.substring(PREFIX_PLUGIN_ALL.length), mode);
setMode(PREFIX_PLUGIN_MAIN + key.substring(PREFIX_PLUGIN_ALL.length), mode); setMode(PREFIX_PLUGIN_MAIN + key.substring(PREFIX_PLUGIN_ALL.length), mode);
return;
} }
const files = unique( const files = unique(
list list
@@ -176,17 +188,23 @@
.flat() .flat()
.map((e) => e.filename), .map((e) => e.filename),
); );
automaticList.set(key, mode); if (mode == MODE_SELECTIVE) {
automaticListDisp = automaticList; automaticList.delete(key);
if (!(key in plugin.settings.pluginSyncExtendedSetting)) { delete plugin.settings.pluginSyncExtendedSetting[key];
plugin.settings.pluginSyncExtendedSetting[key] = { automaticListDisp = automaticList;
key, } else {
mode, automaticList.set(key, mode);
files: [], automaticListDisp = automaticList;
}; if (!(key in plugin.settings.pluginSyncExtendedSetting)) {
plugin.settings.pluginSyncExtendedSetting[key] = {
key,
mode,
files: [],
};
}
plugin.settings.pluginSyncExtendedSetting[key].files = files;
plugin.settings.pluginSyncExtendedSetting[key].mode = mode;
} }
plugin.settings.pluginSyncExtendedSetting[key].files = files;
plugin.settings.pluginSyncExtendedSetting[key].mode = mode;
plugin.saveSettingData(); plugin.saveSettingData();
} }
function getIcon(mode: SYNC_MODE) { function getIcon(mode: SYNC_MODE) {
@@ -208,9 +226,9 @@
let displayKeys: Record<string, string[]> = {}; let displayKeys: Record<string, string[]> = {};
$: { function computeDisplayKeys(list: IPluginDataExDisplay[]) {
const extraKeys = Object.keys(plugin.settings.pluginSyncExtendedSetting); const extraKeys = Object.keys(plugin.settings.pluginSyncExtendedSetting);
displayKeys = [ return [
...list, ...list,
...extraKeys ...extraKeys
.map((e) => `${e}///`.split("/")) .map((e) => `${e}///`.split("/"))
@@ -220,6 +238,9 @@
.sort((a, b) => (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name)) .sort((a, b) => (a.displayName ?? a.name).localeCompare(b.displayName ?? b.name))
.reduce((p, c) => ({ ...p, [c.category]: unique(c.category in p ? [...p[c.category], c.displayName ?? c.name] : [c.displayName ?? c.name]) }), {} as Record<string, string[]>); .reduce((p, c) => ({ ...p, [c.category]: unique(c.category in p ? [...p[c.category], c.displayName ?? c.name] : [c.displayName ?? c.name]) }), {} as Record<string, string[]>);
} }
$: {
displayKeys = computeDisplayKeys(list);
}
let deleteTerm = ""; let deleteTerm = "";
@@ -230,146 +251,203 @@
} }
addOn.reloadPluginList(true); addOn.reloadPluginList(true);
} }
let nameMap = new Map<string, string>();
function updateNameMap(e: Map<string, PluginManifest>) {
const items = [...e.entries()].map(([k, v]) => [k.split("/").slice(-2).join("/"), v.name] as [string, string]);
const newMap = new Map(items);
if (newMap.size == nameMap.size) {
let diff = false;
for (const [k, v] of newMap) {
if (nameMap.get(k) != v) {
diff = true;
break;
}
}
if (!diff) {
return;
}
}
nameMap = newMap;
}
$: updateNameMap($pluginManifestStore);
let displayEntries = [] as [string, string][];
$: {
displayEntries = Object.entries(displays).filter(([key, _]) => key in displayKeys);
}
let pluginEntries = [] as [string, IPluginDataExDisplay[]][];
$: {
pluginEntries = groupBy(filterList(list, ["PLUGIN_MAIN", "PLUGIN_DATA", "PLUGIN_ETC"]), "name");
}
let useSyncPluginEtc = plugin.settings.usePluginEtc;
</script> </script>
<div> <div class="buttonsWrap">
<div> <div class="buttons">
<div class="buttons"> <button on:click={() => scanAgain()}>Scan changes</button>
<button on:click={() => scanAgain()}>Scan changes</button> <button on:click={() => replicate()}>Sync once</button>
<button on:click={() => replicate()}>Sync once</button> <button on:click={() => requestUpdate()}>Refresh</button>
<button on:click={() => requestUpdate()}>Refresh</button> {#if isMaintenanceMode}
{#if isMaintenanceMode} <button on:click={() => requestReload()}>Reload</button>
<button on:click={() => requestReload()}>Reload</button> {/if}
{/if}
<button on:click={() => selectAllNewest()}>Select All Shiny</button>
</div>
<div class="buttons">
<button on:click={() => applyAll()}>Apply All</button>
</div>
</div> </div>
{#if loading} <div class="buttons">
<div> <button on:click={() => selectAllNewest(true)}>Select All Shiny</button>
<span>Updating list...</span> <button on:click={() => selectAllNewest(false)}>{ICON_EMOJI_FLAGGED} Select Flagged Shiny</button>
</div> <button on:click={() => resetSelectNewest()}>Deselect all</button>
<button on:click={() => applyAll()} class="mod-cta">Apply All Selected</button>
</div>
</div>
<div class="loading">
{#if loading || $pluginV2Progress !== 0}
<span>Updating list...{$pluginV2Progress == 0 ? "" : ` (${$pluginV2Progress})`}</span>
{/if} {/if}
<div class="list"> </div>
{#if list.length == 0} <div class="list">
<div class="center">No Items.</div> {#if list.length == 0}
{:else} <div class="center">No Items.</div>
{#each Object.entries(displays).filter(([key, _]) => key in displayKeys) as [key, label]} {:else}
<div> {#each displayEntries as [key, label]}
<h3>{label}</h3> <div>
{#each displayKeys[key] as name} <h3>{label}</h3>
{@const bindKey = `${key}/${name}`} {#each displayKeys[key] as name}
{@const mode = automaticListDisp.get(bindKey) ?? MODE_SELECTIVE} {@const bindKey = `${key}/${name}`}
<div class="labelrow {hideEven ? 'hideeven' : ''}"> {@const mode = automaticListDisp.get(bindKey) ?? MODE_SELECTIVE}
<div class="title"> <div class="labelrow {hideEven ? 'hideeven' : ''}">
<button class="status" on:click={(evt) => askMode(evt, `${key}/${name}`, bindKey)}> <div class="title">
{getIcon(mode)} <button class="status" on:click={(evt) => askMode(evt, `${key}/${name}`, bindKey)}>
</button> {getIcon(mode)}
<span class="name">{name}</span> </button>
</div> <span class="name">{(key == "THEME" && nameMap.get(`themes/${name}`)) || name}</span>
{#if mode == MODE_SELECTIVE} </div>
<PluginCombo {...options} list={list.filter((e) => e.category == key && e.name == name)} hidden={false} /> <div class="body">
{#if mode == MODE_SELECTIVE || mode == MODE_SHINY}
<PluginCombo {...options} isFlagged={mode == MODE_SHINY} list={list.filter((e) => e.category == key && e.name == name)} hidden={false} />
{:else} {:else}
<div class="statusnote">{TITLES[mode]}</div> <div class="statusnote">{TITLES[mode]}</div>
{/if} {/if}
</div> </div>
{/each} </div>
</div> {/each}
{/each} </div>
<div> {/each}
<h3>Plugins</h3> <div>
{#each groupBy(filterList(list, ["PLUGIN_MAIN", "PLUGIN_DATA", "PLUGIN_ETC"]), "name") as [name, listX]} <h3>Plugins</h3>
{@const bindKeyAll = `${PREFIX_PLUGIN_ALL}/${name}`} {#each pluginEntries as [name, listX]}
{@const modeAll = automaticListDisp.get(bindKeyAll) ?? MODE_SELECTIVE} {@const bindKeyAll = `${PREFIX_PLUGIN_ALL}/${name}`}
{@const bindKeyMain = `${PREFIX_PLUGIN_MAIN}/${name}`} {@const modeAll = automaticListDisp.get(bindKeyAll) ?? MODE_SELECTIVE}
{@const modeMain = automaticListDisp.get(bindKeyMain) ?? MODE_SELECTIVE} {@const bindKeyMain = `${PREFIX_PLUGIN_MAIN}/${name}`}
{@const bindKeyData = `${PREFIX_PLUGIN_DATA}/${name}`} {@const modeMain = automaticListDisp.get(bindKeyMain) ?? MODE_SELECTIVE}
{@const modeData = automaticListDisp.get(bindKeyData) ?? MODE_SELECTIVE} {@const bindKeyData = `${PREFIX_PLUGIN_DATA}/${name}`}
<div class="labelrow {hideEven ? 'hideeven' : ''}"> {@const modeData = automaticListDisp.get(bindKeyData) ?? MODE_SELECTIVE}
<div class="title"> {@const bindKeyETC = `${PREFIX_PLUGIN_ETC}/${name}`}
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ALL}/${name}`, bindKeyAll)}> {@const modeEtc = automaticListDisp.get(bindKeyETC) ?? MODE_SELECTIVE}
{getIcon(modeAll)} <div class="labelrow {hideEven ? 'hideeven' : ''}">
</button> <div class="title">
<span class="name">{name}</span> <button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ALL}/${name}`, bindKeyAll)}>
</div> {getIcon(modeAll)}
{#if modeAll == MODE_SELECTIVE} </button>
<PluginCombo {...options} list={listX} hidden={true} /> <span class="name">{nameMap.get(`plugins/${name}`) || name}</span>
</div>
<div class="body">
{#if modeAll == MODE_SELECTIVE || modeAll == MODE_SHINY}
<PluginCombo {...options} isFlagged={modeAll == MODE_SHINY} list={listX} hidden={true} />
{/if} {/if}
</div> </div>
{#if modeAll == MODE_SELECTIVE} </div>
<div class="filerow {hideEven ? 'hideeven' : ''}"> {#if modeAll == MODE_SELECTIVE || modeAll == MODE_SHINY}
<div class="filetitle"> <div class="filerow {hideEven ? 'hideeven' : ''}">
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_MAIN}/${name}/MAIN`, bindKeyMain)}> <div class="filetitle">
{getIcon(modeMain)} <button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_MAIN}/${name}/MAIN`, bindKeyMain)}>
</button> {getIcon(modeMain)}
<span class="name">MAIN</span> </button>
</div> <span class="name">MAIN</span>
{#if modeMain == MODE_SELECTIVE} </div>
<PluginCombo {...options} list={filterList(listX, ["PLUGIN_MAIN"])} hidden={false} /> <div class="body">
{#if modeMain == MODE_SELECTIVE || modeMain == MODE_SHINY}
<PluginCombo {...options} isFlagged={modeMain == MODE_SHINY} list={filterList(listX, ["PLUGIN_MAIN"])} hidden={false} />
{:else} {:else}
<div class="statusnote">{TITLES[modeMain]}</div> <div class="statusnote">{TITLES[modeMain]}</div>
{/if} {/if}
</div> </div>
<div class="filerow {hideEven ? 'hideeven' : ''}"> </div>
<div class="filetitle"> <div class="filerow {hideEven ? 'hideeven' : ''}">
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_DATA}/${name}`, bindKeyData)}> <div class="filetitle">
{getIcon(modeData)} <button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_DATA}/${name}`, bindKeyData)}>
</button> {getIcon(modeData)}
<span class="name">DATA</span> </button>
</div> <span class="name">DATA</span>
{#if modeData == MODE_SELECTIVE} </div>
<PluginCombo {...options} list={filterList(listX, ["PLUGIN_DATA"])} hidden={false} /> <div class="body">
{#if modeData == MODE_SELECTIVE || modeData == MODE_SHINY}
<PluginCombo {...options} isFlagged={modeData == MODE_SHINY} list={filterList(listX, ["PLUGIN_DATA"])} hidden={false} />
{:else} {:else}
<div class="statusnote">{TITLES[modeData]}</div> <div class="statusnote">{TITLES[modeData]}</div>
{/if} {/if}
</div> </div>
{:else} </div>
<div class="noterow"> {#if useSyncPluginEtc}
<div class="statusnote">{TITLES[modeAll]}</div> <div class="filerow {hideEven ? 'hideeven' : ''}">
<div class="filetitle">
<button class="status" on:click={(evt) => askMode(evt, `${PREFIX_PLUGIN_ETC}/${name}`, bindKeyETC)}>
{getIcon(modeEtc)}
</button>
<span class="name">Other files</span>
</div>
<div class="body">
{#if modeEtc == MODE_SELECTIVE || modeEtc == MODE_SHINY}
<PluginCombo {...options} isFlagged={modeEtc == MODE_SHINY} list={filterList(listX, ["PLUGIN_ETC"])} hidden={false} />
{:else}
<div class="statusnote">{TITLES[modeEtc]}</div>
{/if}
</div>
</div> </div>
{/if} {/if}
{/each} {:else}
</div> <div class="noterow">
{/if} <div class="statusnote">{TITLES[modeAll]}</div>
</div> </div>
{#if isMaintenanceMode} {/if}
<div class="list"> {/each}
<div>
<h3>Maintenance Commands</h3>
<div class="maintenancerow">
<label for="">Delete All of </label>
<select bind:value={deleteTerm}>
{#each allTerms as term}
<option value={term}>{term}</option>
{/each}
</select>
<button
class="status"
on:click={(evt) => {
deleteAllItems(deleteTerm);
}}
>
🗑️
</button>
</div>
</div>
</div> </div>
{/if} {/if}
</div>
{#if isMaintenanceMode}
<div class="buttons"> <div class="buttons">
<label><span>Hide not applicable items</span><input type="checkbox" bind:checked={hideEven} /></label> <div>
</div> <h3>Maintenance Commands</h3>
<div class="buttons"> <div class="maintenancerow">
<label><span>Maintenance mode</span><input type="checkbox" bind:checked={isMaintenanceMode} /></label> <label for="">Delete All of </label>
<select bind:value={deleteTerm}>
{#each allTerms as term}
<option value={term}>{term}</option>
{/each}
</select>
<button
class="status"
on:click={(evt) => {
deleteAllItems(deleteTerm);
}}
>
🗑️
</button>
</div>
</div>
</div> </div>
{/if}
<div class="buttons">
<label><span>Hide not applicable items</span><input type="checkbox" bind:checked={hideEven} /></label>
</div>
<div class="buttons">
<label><span>Maintenance mode</span><input type="checkbox" bind:checked={isMaintenanceMode} /></label>
</div> </div>
<style> <style>
/* span.spacer { .buttonsWrap {
min-width: 1px; padding-bottom: 4px;
flex-grow: 1; }
} */
h3 { h3 {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -414,13 +492,23 @@
min-width: 10em; min-width: 10em;
flex-grow: 1; flex-grow: 1;
} }
.list {
overflow-y: auto;
}
.title { .title {
color: var(--text-normal); color: var(--text-normal);
font-size: var(--font-ui-medium); font-size: var(--font-ui-medium);
line-height: var(--line-height-tight); line-height: var(--line-height-tight);
margin-right: auto; margin-right: auto;
} }
.body {
/* margin-left: 0.4em; */
margin-left: auto;
display: flex;
justify-content: flex-start;
align-items: center;
/* flex-wrap: wrap; */
}
.filetitle { .filetitle {
color: var(--text-normal); color: var(--text-normal);
font-size: var(--font-ui-medium); font-size: var(--font-ui-medium);
@@ -467,4 +555,24 @@
margin-right: 0.5em; margin-right: 0.5em;
margin-left: 0.5em; margin-left: 0.5em;
} }
.loading {
transition: height 0.25s ease-in-out;
transition-delay: 4ms;
overflow-y: hidden;
flex-shrink: 0;
display: flex;
justify-content: flex-start;
align-items: center;
}
.loading:empty {
height: 0px;
transition: height 0.25s ease-in-out;
transition-delay: 1s;
}
.loading:not(:empty) {
height: 2em;
transition: height 0.25s ease-in-out;
transition-delay: 0;
}
</style> </style>

View File

@@ -1,39 +1,42 @@
<script lang="ts"> <script lang="ts">
import type { PluginDataExDisplay } from "../../features/CmdConfigSync"; import { PluginDataExDisplayV2, type IPluginDataExDisplay } from "../../features/CmdConfigSync";
import { Logger } from "../../lib/src/common/logger"; import { Logger } from "../../lib/src/common/logger";
import { versionNumberString2Number } from "../../lib/src/string_and_binary/convert"; import { type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../../lib/src/common/types";
import { type FilePath, LOG_LEVEL_NOTICE } from "../../lib/src/common/types"; import { getDocData, timeDeltaToHumanReadable, unique } from "../../lib/src/common/utils";
import { getDocData } from "../../lib/src/common/utils";
import type ObsidianLiveSyncPlugin from "../../main"; import type ObsidianLiveSyncPlugin from "../../main";
import { askString, scheduleTask } from "../../common/utils"; import { askString } from "../../common/utils";
import { Menu } from "obsidian";
export let list: PluginDataExDisplay[] = []; export let list: IPluginDataExDisplay[] = [];
export let thisTerm = ""; export let thisTerm = "";
export let hideNotApplicable = false; export let hideNotApplicable = false;
export let selectNewest = 0; export let selectNewest = 0;
export let selectNewestStyle = 0;
export let applyAllPluse = 0; export let applyAllPluse = 0;
export let applyData: (data: PluginDataExDisplay) => Promise<boolean>; export let applyData: (data: IPluginDataExDisplay) => Promise<boolean>;
export let compareData: (dataA: PluginDataExDisplay, dataB: PluginDataExDisplay) => Promise<boolean>; export let compareData: (dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach?: boolean) => Promise<boolean>;
export let deleteData: (data: PluginDataExDisplay) => Promise<boolean>; export let deleteData: (data: IPluginDataExDisplay) => Promise<boolean>;
export let hidden: boolean; export let hidden: boolean;
export let plugin: ObsidianLiveSyncPlugin; export let plugin: ObsidianLiveSyncPlugin;
export let isMaintenanceMode: boolean = false; export let isMaintenanceMode: boolean = false;
export let isFlagged: boolean = false;
const addOn = plugin.addOnConfigSync; const addOn = plugin.addOnConfigSync;
let selected = ""; export let selected = "";
let freshness = ""; let freshness = "";
let equivalency = ""; let equivalency = "";
let version = ""; let version = "";
let canApply: boolean = false; let canApply: boolean = false;
let canCompare: boolean = false; let canCompare: boolean = false;
let pickToCompare: boolean = false;
let currentSelectNewest = 0; let currentSelectNewest = 0;
let currentApplyAll = 0; let currentApplyAll = 0;
// Selectable terminals // Selectable terminals
let terms = [] as string[]; let terms = [] as string[];
async function comparePlugin(local: PluginDataExDisplay, remote: PluginDataExDisplay) { async function comparePlugin(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined) {
let freshness = ""; let freshness = "";
let equivalency = ""; let equivalency = "";
let version = ""; let version = "";
@@ -41,25 +44,28 @@
let canApply: boolean = false; let canApply: boolean = false;
let canCompare = false; let canCompare = false;
if (!local && !remote) { if (!local && !remote) {
// NO OP. whats happened? // NO OP. what's happened?
freshness = ""; freshness = "";
} else if (local && !remote) { } else if (local && !remote) {
freshness = "Local only"; freshness = "Local only";
} else if (remote && !local) { } else if (remote && !local) {
freshness = "Remote only"; freshness = "Remote only";
canApply = true; canApply = true;
} else { } else {
const dtDiff = (local?.mtime ?? 0) - (remote?.mtime ?? 0); const dtDiff = (local?.mtime ?? 0) - (remote?.mtime ?? 0);
const diff = timeDeltaToHumanReadable(Math.abs(dtDiff / 1000));
if (dtDiff / 1000 < -10) { if (dtDiff / 1000 < -10) {
freshness = "✓ Newer"; // freshness = "✓ Newer";
freshness = `Newer (${diff})`;
canApply = true; canApply = true;
contentCheck = true; contentCheck = true;
} else if (dtDiff / 1000 > 10) { } else if (dtDiff / 1000 > 10) {
freshness = "⚠ Older"; // freshness = "⚠ Older";
freshness = `Older (${diff})`;
canApply = true; canApply = true;
contentCheck = true; contentCheck = true;
} else { } else {
freshness = "⚖️ Same old"; freshness = "Same";
canApply = false; canApply = false;
contentCheck = true; contentCheck = true;
} }
@@ -67,25 +73,26 @@
const localVersionStr = local?.version || "0.0.0"; const localVersionStr = local?.version || "0.0.0";
const remoteVersionStr = remote?.version || "0.0.0"; const remoteVersionStr = remote?.version || "0.0.0";
if (local?.version || remote?.version) { if (local?.version || remote?.version) {
const localVersion = versionNumberString2Number(localVersionStr); const compare = `${localVersionStr}`.localeCompare(remoteVersionStr, undefined, { numeric: true });
const remoteVersion = versionNumberString2Number(remoteVersionStr); if (compare == 0) {
if (localVersion == remoteVersion) { version = "Same";
version = "⚖️ Same ver."; } else if (compare < 0) {
} else if (localVersion > remoteVersion) { version = `Lower (${localVersionStr} < ${remoteVersionStr})`;
version = `⚠ Lower ${localVersionStr} > ${remoteVersionStr}`; } else if (compare > 0) {
} else if (localVersion < remoteVersion) { version = `Higher (${localVersionStr} > ${remoteVersionStr})`;
version = `✓ Higher ${localVersionStr} < ${remoteVersionStr}`;
} }
} }
if (contentCheck) { if (contentCheck) {
const { canApply, equivalency, canCompare } = await checkEquivalency(local, remote); if (local && remote) {
return { canApply, freshness, equivalency, version, canCompare }; const { canApply, equivalency, canCompare } = await checkEquivalency(local, remote);
return { canApply, freshness, equivalency, version, canCompare };
}
} }
return { canApply, freshness, equivalency, version, canCompare }; return { canApply, freshness, equivalency, version, canCompare };
} }
async function checkEquivalency(local: PluginDataExDisplay, remote: PluginDataExDisplay) { async function checkEquivalency(local: IPluginDataExDisplay, remote: IPluginDataExDisplay) {
let equivalency = ""; let equivalency = "";
let canApply = false; let canApply = false;
let canCompare = false; let canCompare = false;
@@ -100,17 +107,21 @@
return 0b0000010; //"LOCAL_ONLY"; return 0b0000010; //"LOCAL_ONLY";
} else if (!localFile && remoteFile) { } else if (!localFile && remoteFile) {
return 0b0001000; //"REMOTE ONLY" return 0b0001000; //"REMOTE ONLY"
} else { } else if (localFile && remoteFile) {
if (getDocData(localFile.data) == getDocData(remoteFile.data)) { const localDoc = getDocData(localFile.data);
const remoteDoc = getDocData(remoteFile.data);
if (localDoc == remoteDoc) {
return 0b0000100; //"EVEN" return 0b0000100; //"EVEN"
} else { } else {
return 0b0010000; //"DIFFERENT"; return 0b0010000; //"DIFFERENT";
} }
} else {
return 0b0010000; //"DIFFERENT";
} }
}) })
.reduce((p, c) => p | (c as number), 0 as number); .reduce((p, c) => p | (c as number), 0 as number);
if (matchingStatus == 0b0000100) { if (matchingStatus == 0b0000100) {
equivalency = "⚖️ Same"; equivalency = "Same";
canApply = false; canApply = false;
} else if (matchingStatus <= 0b0000100) { } else if (matchingStatus <= 0b0000100) {
equivalency = "Same or local only"; equivalency = "Same or local only";
@@ -118,30 +129,37 @@
} else if (matchingStatus == 0b0010000) { } else if (matchingStatus == 0b0010000) {
canApply = true; canApply = true;
canCompare = true; canCompare = true;
equivalency = "Different"; equivalency = "Different";
} else { } else {
canApply = true; canApply = true;
canCompare = true; canCompare = true;
equivalency = "≠ Different"; equivalency = "Mixed";
} }
return { equivalency, canApply, canCompare }; return { equivalency, canApply, canCompare };
} }
async function performCompare(local: PluginDataExDisplay, remote: PluginDataExDisplay) { async function performCompare(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined) {
const result = await comparePlugin(local, remote); const result = await comparePlugin(local, remote);
canApply = result.canApply; canApply = result.canApply;
freshness = result.freshness; freshness = result.freshness;
equivalency = result.equivalency; equivalency = result.equivalency;
version = result.version; version = result.version;
canCompare = result.canCompare; canCompare = result.canCompare;
if (local?.files.length != 1 || !local?.files?.first()?.filename?.endsWith(".json")) { pickToCompare = false;
canCompare = false; if (canCompare) {
if (local?.files.length == remote?.files.length && local?.files.length == 1 && local?.files[0].filename == remote?.files[0].filename) {
pickToCompare = false;
} else {
pickToCompare = true;
// pickToCompare = false;
// canCompare = false;
}
} }
} }
async function updateTerms(list: PluginDataExDisplay[], selectNewest: boolean, isMaintenanceMode: boolean) { async function updateTerms(list: IPluginDataExDisplay[], selectNewest: boolean, isMaintenanceMode: boolean) {
const local = list.find((e) => e.term == thisTerm); const local = list.find((e) => e.term == thisTerm);
selected = ""; // selected = "";
if (isMaintenanceMode) { if (isMaintenanceMode) {
terms = [...new Set(list.map((e) => e.term))]; terms = [...new Set(list.map((e) => e.term))];
} else if (hideNotApplicable) { } else if (hideNotApplicable) {
@@ -157,7 +175,7 @@
} else { } else {
terms = [...new Set(list.map((e) => e.term))].filter((e) => e != thisTerm); terms = [...new Set(list.map((e) => e.term))].filter((e) => e != thisTerm);
} }
let newest: PluginDataExDisplay = local; let newest: IPluginDataExDisplay | undefined = local;
if (selectNewest) { if (selectNewest) {
for (const term of terms) { for (const term of terms) {
const remote = list.find((e) => e.term == term); const remote = list.find((e) => e.term == term);
@@ -170,12 +188,25 @@
} }
// selectNewest = false; // selectNewest = false;
} }
if (terms.indexOf(selected) < 0) {
selected = "";
}
} }
$: { $: {
// React pulse and select // React pulse and select
const doSelectNewest = selectNewest != currentSelectNewest; let doSelectNewest = false;
currentSelectNewest = selectNewest; if (selectNewest != currentSelectNewest) {
if (selectNewestStyle == 1) {
doSelectNewest = true;
} else if (selectNewestStyle == 2) {
doSelectNewest = isFlagged;
} else if (selectNewestStyle == 3) {
selected = "";
}
// currentSelectNewest = selectNewest;
}
updateTerms(list, doSelectNewest, isMaintenanceMode); updateTerms(list, doSelectNewest, isMaintenanceMode);
currentSelectNewest = selectNewest;
} }
$: { $: {
// React pulse and apply // React pulse and apply
@@ -213,10 +244,52 @@
async function compareSelected() { async function compareSelected() {
const local = list.find((e) => e.term == thisTerm); const local = list.find((e) => e.term == thisTerm);
const selectedItem = list.find((e) => e.term == selected); const selectedItem = list.find((e) => e.term == selected);
if (local && selectedItem && (await compareData(local, selectedItem))) { await compareItems(local, selectedItem);
addOn.updatePluginList(true, local.documentPath); }
async function compareItems(local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined, filename?: string) {
if (local && remote) {
if (!filename) {
if (await compareData(local, remote)) {
addOn.updatePluginList(true, local.documentPath);
}
return;
} else {
const localCopy = local instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(local) : { ...local };
const remoteCopy = remote instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(remote) : { ...remote };
localCopy.files = localCopy.files.filter((e) => e.filename == filename);
remoteCopy.files = remoteCopy.files.filter((e) => e.filename == filename);
if (await compareData(localCopy, remoteCopy, true)) {
addOn.updatePluginList(true, local.documentPath);
}
}
return;
} else {
if (!remote && !local) {
Logger(`Could not find both remote and local item`, LOG_LEVEL_INFO);
} else if (!remote) {
Logger(`Could not find remote item`, LOG_LEVEL_INFO);
} else if (!local) {
Logger(`Could not locally item`, LOG_LEVEL_INFO);
}
} }
} }
async function pickCompareItem(evt: MouseEvent) {
const local = list.find((e) => e.term == thisTerm);
const selectedItem = list.find((e) => e.term == selected);
if (!local) return;
if (!selectedItem) return;
const menu = new Menu();
menu.addItem((item) => item.setTitle("Compare file").setIsLabel(true));
menu.addSeparator();
const files = unique(local.files.map((e) => e.filename).concat(selectedItem.files.map((e) => e.filename)));
for (const filename of files) {
menu.addItem((item) => {
item.setTitle(filename).onClick((e) => compareItems(local, selectedItem, filename));
});
}
menu.showAtMouseEvent(evt);
}
async function deleteSelected() { async function deleteSelected() {
const selectedItem = list.find((e) => e.term == selected); const selectedItem = list.find((e) => e.term == selected);
// const deletedPath = selectedItem.documentPath; // const deletedPath = selectedItem.documentPath;
@@ -226,6 +299,10 @@
} }
async function duplicateItem() { async function duplicateItem() {
const local = list.find((e) => e.term == thisTerm); const local = list.find((e) => e.term == thisTerm);
if (!local) {
Logger(`Could not find local item`, LOG_LEVEL_VERBOSE);
return;
}
const duplicateTermName = await askString(plugin.app, "Duplicate", "device name", ""); const duplicateTermName = await askString(plugin.app, "Duplicate", "device name", "");
if (duplicateTermName) { if (duplicateTermName) {
if (duplicateTermName.contains("/")) { if (duplicateTermName.contains("/")) {
@@ -242,10 +319,10 @@
{#if terms.length > 0} {#if terms.length > 0}
<span class="spacer" /> <span class="spacer" />
{#if !hidden} {#if !hidden}
<span class="messages"> <span class="chip-wrap">
<span class="message">{freshness}</span> <span class="chip modified">{freshness}</span>
<span class="message">{equivalency}</span> <span class="chip content">{equivalency}</span>
<span class="message">{version}</span> <span class="chip version">{version}</span>
</span> </span>
<select bind:value={selected}> <select bind:value={selected}>
<option value={""}>-</option> <option value={""}>-</option>
@@ -255,7 +332,12 @@
</select> </select>
{#if canApply || (isMaintenanceMode && selected != "")} {#if canApply || (isMaintenanceMode && selected != "")}
{#if canCompare} {#if canCompare}
<button on:click={compareSelected}>🔍</button> {#if pickToCompare}
<button on:click={pickCompareItem}>🗃️</button>
{:else}
<!--🔍 -->
<button on:click={compareSelected}>⮂</button>
{/if}
{:else} {:else}
<button disabled /> <button disabled />
{/if} {/if}
@@ -307,12 +389,46 @@
padding: 0 1em; padding: 0 1em;
line-height: var(--line-height-tight); line-height: var(--line-height-tight);
} }
span.messages { /* span.messages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} } */
:global(.is-mobile) .spacer { :global(.is-mobile) .spacer {
margin-left: auto; margin-left: auto;
} }
.chip-wrap {
display: flex;
gap: 2px;
flex-direction: column;
justify-content: center;
align-items: flex-start;
}
.chip {
display: inline-block;
border-radius: 2px;
font-size: 0.8em;
padding: 0 4px;
margin: 0 2px;
border-color: var(--tag-border-color);
background-color: var(--tag-background);
color: var(--tag-color);
}
.chip:empty {
display: none;
}
.chip:not(:empty)::before {
min-width: 1.8em;
display: inline-block;
}
.chip.content:not(:empty)::before {
content: "📄: ";
}
.chip.version:not(:empty)::before {
content: "🏷️: ";
}
.chip.modified:not(:empty)::before {
content: "📅: ";
}
</style> </style>

View File

@@ -324,6 +324,10 @@ export const SettingInformation: Partial<Record<keyof AllSettings, Configuration
"notifyThresholdOfRemoteStorageSize": { "notifyThresholdOfRemoteStorageSize": {
name: "Notify when the estimated remote storage size exceeds on start up", name: "Notify when the estimated remote storage size exceeds on start up",
desc: "MB (0 to disable)." desc: "MB (0 to disable)."
},
"usePluginSyncV2": {
name: "Enable per-file-saved customization sync",
desc: "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions."
} }
} }
function translateInfo(infoSrc: ConfigurationItem | undefined | false) { function translateInfo(infoSrc: ConfigurationItem | undefined | false) {

View File

@@ -18,6 +18,23 @@ I have a lot of respect for that plugin, even though it is sometimes treated as
Hooray for open source, and generous licences, and the sharing of knowledge by experts. Hooray for open source, and generous licences, and the sharing of knowledge by experts.
#### Version history #### Version history
- 0.23.18:
- New feature:
- Per-file-saved customization sync has been shipped.
- We can synchronise plug-igs etc., more smoothly.
- Default: disabled. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost compatibility with old versions.
- Customisation sync has got beta3.
- We can set `Flag` to each item to select the newest, automatically.
- This configuration is per device.
- Improved:
- Start-up speed has been improved.
- Fixed:
- On the customisation sync dialogue, buttons are kept within the screen.
- No more unnecessary entries on `data.json` for customisation sync.
- Selections are no longer lost while updating customisation items.
- Tidied on source codes:
- Many typos have been fixed.
- Some unnecessary type casting removed.
- 0.23.17: - 0.23.17:
- Improved: - Improved:
- Overall performance has been improved by using PouchDB 9.0.0. - Overall performance has been improved by using PouchDB 9.0.0.