mirror of
https://github.com/silvanocerza/github-gitless-sync.git
synced 2026-07-22 12:10:28 +00:00
922 lines
31 KiB
TypeScript
922 lines
31 KiB
TypeScript
import { Vault, Notice, normalizePath, base64ToArrayBuffer } from "obsidian";
|
|
import GithubClient, {
|
|
GetTreeResponseItem,
|
|
NewTreeRequestItem,
|
|
RepoContent,
|
|
} from "./github/client";
|
|
import MetadataStore, {
|
|
FileMetadata,
|
|
Metadata,
|
|
MANIFEST_FILE_NAME,
|
|
} from "./metadata-store";
|
|
import EventsListener from "./events-listener";
|
|
import { GitHubSyncSettings } from "./settings/settings";
|
|
import Logger from "./logger";
|
|
import { decodeBase64String } from "./utils";
|
|
|
|
interface SyncAction {
|
|
type: "upload" | "download" | "delete_local" | "delete_remote";
|
|
filePath: string;
|
|
}
|
|
|
|
export interface ConflictFile {
|
|
filePath: string;
|
|
remoteContent: string;
|
|
localContent: string;
|
|
}
|
|
|
|
export interface ConflictResolution {
|
|
filePath: string;
|
|
content: string;
|
|
}
|
|
|
|
type OnConflictsCallback = (
|
|
conflicts: ConflictFile[],
|
|
) => Promise<ConflictResolution[]>;
|
|
|
|
export default class SyncManager {
|
|
private metadataStore: MetadataStore;
|
|
private client: GithubClient;
|
|
private eventsListener: EventsListener;
|
|
private syncIntervalId: number | null = null;
|
|
|
|
// Use to track if syncing is in progress, this ideally
|
|
// prevents multiple syncs at the same time and creation
|
|
// of messy conflicts.
|
|
private syncing: boolean = false;
|
|
|
|
constructor(
|
|
private vault: Vault,
|
|
private settings: GitHubSyncSettings,
|
|
private onConflicts: OnConflictsCallback,
|
|
private logger: Logger,
|
|
) {
|
|
this.metadataStore = new MetadataStore(this.vault);
|
|
this.client = new GithubClient(this.settings, this.logger);
|
|
this.eventsListener = new EventsListener(
|
|
this.vault,
|
|
this.metadataStore,
|
|
this.settings,
|
|
this.logger,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns true if the local vault root is empty.
|
|
*/
|
|
private async vaultIsEmpty(): Promise<boolean> {
|
|
const { files, folders } = await this.vault.adapter.list(
|
|
this.vault.getRoot().path,
|
|
);
|
|
// There are files or folders in the vault dir
|
|
return (
|
|
files.length === 0 ||
|
|
// We filter out the config dir since is always present so it's fine if we find it.
|
|
folders.filter((f) => f !== this.vault.configDir).length === 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handles first sync with remote and local.
|
|
* This fails if neither remote nor local folders are empty.
|
|
*/
|
|
async firstSync() {
|
|
if (this.syncing) {
|
|
this.logger.info("First sync already in progress");
|
|
// We're already syncing, nothing to do
|
|
return;
|
|
}
|
|
|
|
this.syncing = true;
|
|
try {
|
|
this.firstSyncImpl();
|
|
} finally {
|
|
this.syncing = false;
|
|
}
|
|
}
|
|
|
|
private async firstSyncImpl() {
|
|
await this.logger.info("Starting first sync");
|
|
let repositoryIsBare = false;
|
|
let res: RepoContent;
|
|
let files: {
|
|
[key: string]: GetTreeResponseItem;
|
|
} = {};
|
|
let treeSha: string = "";
|
|
try {
|
|
res = await this.client.getRepoContent();
|
|
files = res.files;
|
|
treeSha = res.sha;
|
|
} catch (err) {
|
|
if (err.status !== 409) {
|
|
this.syncing = false;
|
|
throw err;
|
|
}
|
|
// The repository is bare, meaning it has no tree, no commits and no branches
|
|
repositoryIsBare = true;
|
|
}
|
|
|
|
if (repositoryIsBare) {
|
|
await this.logger.info("Remote repository is bare");
|
|
// Since the repository is completely empty we need to create a first commit.
|
|
// We can't create that by going throught the normal sync process since the
|
|
// API doesn't let us create a new tree when the repo is empty.
|
|
// So we create a the manifest file as the first commit, since we're going
|
|
// to create that in any case right after this.
|
|
await this.client.createFile(
|
|
`${this.vault.configDir}/${MANIFEST_FILE_NAME}`,
|
|
"",
|
|
"Initial commit",
|
|
);
|
|
// Now get the repo content again cause we know for sure it will return a
|
|
// valid sha that we can use to create the first sync commit.
|
|
res = await this.client.getRepoContent();
|
|
files = res.files;
|
|
treeSha = res.sha;
|
|
}
|
|
|
|
const remoteRepoIsEmpty = Object.keys(files).length === 0;
|
|
const vaultIsEmpty = await this.vaultIsEmpty();
|
|
|
|
if (!remoteRepoIsEmpty && !vaultIsEmpty) {
|
|
// Both have files, we can't sync, show error
|
|
await this.logger.error("Both remote and local have files, can't sync");
|
|
throw new Error("Both remote and local have files, can't sync");
|
|
} else if (remoteRepoIsEmpty || repositoryIsBare) {
|
|
// Remote has no files and no manifest, let's just upload whatever we have locally.
|
|
// This is fine even if the vault is empty.
|
|
// The most important thing at this point is that the remote manifest is created.
|
|
await this.firstSyncFromLocal(files, treeSha);
|
|
} else {
|
|
// Local has no files and there's no manifest in the remote repo.
|
|
// Let's download whatever we have in the remote repo.
|
|
// This is fine even if the remote repo is empty.
|
|
// In this case too the important step is that the remote manifest is created.
|
|
await this.firstSyncFromRemote(files, treeSha);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles first sync with the remote repository.
|
|
* This must be called in case there are no files in the local content dir while
|
|
* remote has files in the repo content dir but no manifest file.
|
|
*
|
|
* @param files All files in the remote repository, including those not in its content dir.
|
|
* @param treeSha The SHA of the tree in the remote repository.
|
|
*/
|
|
private async firstSyncFromRemote(
|
|
files: { [key: string]: GetTreeResponseItem },
|
|
treeSha: string,
|
|
) {
|
|
await this.logger.info("Starting first sync from remote files");
|
|
await Promise.all(
|
|
Object.keys(files)
|
|
.filter((filePath: string) => {
|
|
if (
|
|
this.settings.syncConfigDir &&
|
|
filePath.startsWith(this.vault.configDir) &&
|
|
filePath !== `${this.vault.configDir}/${MANIFEST_FILE_NAME}`
|
|
) {
|
|
// Include files in the config dir only if the user has enabled it.
|
|
// The metadata file must always be synced.
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
.map(async (filePath: string) => {
|
|
await this.downloadFile(files[filePath], Date.now());
|
|
}),
|
|
);
|
|
const newTreeFiles = Object.keys(files)
|
|
.map((filePath: string) => ({
|
|
path: files[filePath].path,
|
|
mode: files[filePath].mode,
|
|
type: files[filePath].type,
|
|
sha: files[filePath].sha,
|
|
}))
|
|
.reduce(
|
|
(
|
|
acc: { [key: string]: NewTreeRequestItem },
|
|
item: NewTreeRequestItem,
|
|
) => ({ ...acc, [item.path]: item }),
|
|
{},
|
|
);
|
|
// Add files that are in the manifest but not in the tree.
|
|
await Promise.all(
|
|
Object.keys(this.metadataStore.data.files).map(
|
|
async (filePath: string) => {
|
|
const normalizedPath = normalizePath(filePath);
|
|
const content = await this.vault.adapter.read(normalizedPath);
|
|
newTreeFiles[filePath] = {
|
|
path: filePath,
|
|
mode: "100644",
|
|
type: "blob",
|
|
content: content,
|
|
};
|
|
},
|
|
),
|
|
);
|
|
await this.commitSync(newTreeFiles, treeSha);
|
|
}
|
|
|
|
/**
|
|
* Handles first sync with the remote repository.
|
|
* This must be called in case there are no files in the remote repo and no manifest while
|
|
* local vault has files and a manifest.
|
|
*
|
|
* @param files All files in the remote repository
|
|
* @param treeSha The SHA of the tree in the remote repository.
|
|
*/
|
|
private async firstSyncFromLocal(
|
|
files: { [key: string]: GetTreeResponseItem },
|
|
treeSha: string,
|
|
) {
|
|
await this.logger.info("Starting first sync from local files");
|
|
const newTreeFiles = Object.keys(files)
|
|
.map((filePath: string) => ({
|
|
path: files[filePath].path,
|
|
mode: files[filePath].mode,
|
|
type: files[filePath].type,
|
|
sha: files[filePath].sha,
|
|
}))
|
|
.reduce(
|
|
(
|
|
acc: { [key: string]: NewTreeRequestItem },
|
|
item: NewTreeRequestItem,
|
|
) => ({ ...acc, [item.path]: item }),
|
|
{},
|
|
);
|
|
await Promise.all(
|
|
Object.keys(this.metadataStore.data.files).map(
|
|
async (filePath: string) => {
|
|
const normalizedPath = normalizePath(filePath);
|
|
const content = await this.vault.adapter.read(normalizedPath);
|
|
newTreeFiles[filePath] = {
|
|
path: filePath,
|
|
mode: "100644",
|
|
type: "blob",
|
|
content: content,
|
|
};
|
|
},
|
|
),
|
|
);
|
|
await this.commitSync(newTreeFiles, treeSha);
|
|
}
|
|
|
|
/**
|
|
* Syncs local and remote folders.
|
|
* @returns
|
|
*/
|
|
async sync() {
|
|
if (this.syncing) {
|
|
this.logger.info("Sync already in progress");
|
|
// We're already syncing, nothing to do
|
|
return;
|
|
}
|
|
|
|
const notice = new Notice("Syncing...");
|
|
this.syncing = true;
|
|
try {
|
|
await this.syncImpl();
|
|
// Shown only if sync doesn't fail
|
|
new Notice("Sync successful", 5000);
|
|
} catch (err) {
|
|
// Show the error to the user, it's not automatically dismissed to make sure
|
|
// the user sees it.
|
|
new Notice(`Error syncing. ${err}`);
|
|
}
|
|
this.syncing = false;
|
|
notice.hide();
|
|
}
|
|
|
|
private async syncImpl() {
|
|
await this.logger.info("Starting sync");
|
|
const { files, sha: treeSha } = await this.client.getRepoContent();
|
|
const manifest = files[`${this.vault.configDir}/${MANIFEST_FILE_NAME}`];
|
|
|
|
if (manifest === undefined) {
|
|
await this.logger.error("Remote manifest is missing", { files, treeSha });
|
|
throw new Error("Remote manifest is missing");
|
|
}
|
|
|
|
const blob = await this.client.getBlob(manifest.sha);
|
|
const remoteMetadata: Metadata = JSON.parse(
|
|
decodeBase64String(blob.content),
|
|
);
|
|
|
|
const conflicts = await this.findConflicts(remoteMetadata.files);
|
|
|
|
// We treat every resolved conflict as an upload SyncAction, mainly cause
|
|
// the user has complete freedom on the edits they can apply to the conflicting files.
|
|
// So when a conflict is resolved we change the file locally and upload it.
|
|
// That solves the conflict.
|
|
let conflictActions: SyncAction[] = [];
|
|
// We keep track of the conflict resolutions cause we want to update the file
|
|
// locally only when we're sure the sync was successul. That happens after we
|
|
// commit the sync.
|
|
let conflictResolutions: ConflictResolution[] = [];
|
|
|
|
if (conflicts.length > 0) {
|
|
await this.logger.warn("Found conflicts", conflicts);
|
|
// Here we block the sync process until the user has resolved all the conflicts
|
|
conflictResolutions = await this.onConflicts(conflicts);
|
|
conflictActions = conflictResolutions.map(
|
|
(resolution: ConflictResolution) => {
|
|
return { type: "upload", filePath: resolution.filePath };
|
|
},
|
|
);
|
|
}
|
|
|
|
const actions: SyncAction[] = [
|
|
...(await this.determineSyncActions(
|
|
remoteMetadata.files,
|
|
this.metadataStore.data.files,
|
|
conflictActions.map((action) => action.filePath),
|
|
)),
|
|
...conflictActions,
|
|
];
|
|
|
|
if (actions.length === 0) {
|
|
// Nothing to sync
|
|
await this.logger.info("Nothing to sync");
|
|
return;
|
|
}
|
|
await this.logger.info("Actions to sync", actions);
|
|
|
|
const newTreeFiles: { [key: string]: NewTreeRequestItem } = Object.keys(
|
|
files,
|
|
)
|
|
.map((filePath: string) => ({
|
|
path: files[filePath].path,
|
|
mode: files[filePath].mode,
|
|
type: files[filePath].type,
|
|
sha: files[filePath].sha,
|
|
}))
|
|
.reduce(
|
|
(
|
|
acc: { [key: string]: NewTreeRequestItem },
|
|
item: NewTreeRequestItem,
|
|
) => ({ ...acc, [item.path]: item }),
|
|
{},
|
|
);
|
|
|
|
await Promise.all(
|
|
actions.map(async (action) => {
|
|
switch (action.type) {
|
|
case "upload": {
|
|
const normalizedPath = normalizePath(action.filePath);
|
|
const resolution = conflictResolutions.find(
|
|
(c: ConflictResolution) => c.filePath === action.filePath,
|
|
);
|
|
// If the file was conflicting we need to read the content from the
|
|
// conflict resolution instead of reading it from file since at this point
|
|
// we still have not updated the local file.
|
|
const content =
|
|
resolution?.content ||
|
|
(await this.vault.adapter.read(normalizedPath));
|
|
newTreeFiles[action.filePath] = {
|
|
path: action.filePath,
|
|
mode: "100644",
|
|
type: "blob",
|
|
content: content,
|
|
};
|
|
break;
|
|
}
|
|
case "delete_remote": {
|
|
newTreeFiles[action.filePath].sha = null;
|
|
break;
|
|
}
|
|
case "download":
|
|
break;
|
|
case "delete_local":
|
|
break;
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Download files and delete local files
|
|
await Promise.all([
|
|
...actions
|
|
.filter((action) => action.type === "download")
|
|
.map(async (action: SyncAction) => {
|
|
await this.downloadFile(
|
|
files[action.filePath],
|
|
remoteMetadata.files[action.filePath].lastModified,
|
|
);
|
|
}),
|
|
...actions
|
|
.filter((action) => action.type === "delete_local")
|
|
.map(async (action: SyncAction) => {
|
|
await this.deleteLocalFile(action.filePath);
|
|
}),
|
|
]);
|
|
|
|
await this.commitSync(newTreeFiles, treeSha, conflictResolutions);
|
|
}
|
|
|
|
/**
|
|
* Finds conflicts between local and remote files.
|
|
* @param filesMetadata Remote files metadata
|
|
* @returns List of object containing file path, remote and local content of conflicting files
|
|
*/
|
|
async findConflicts(filesMetadata: {
|
|
[key: string]: FileMetadata;
|
|
}): Promise<ConflictFile[]> {
|
|
const commonFiles = Object.keys(filesMetadata).filter(
|
|
(key) => key in this.metadataStore.data.files,
|
|
);
|
|
if (commonFiles.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const conflicts = await Promise.all(
|
|
commonFiles.map(async (filePath: string) => {
|
|
if (filePath === `${this.vault.configDir}/${MANIFEST_FILE_NAME}`) {
|
|
// The manifest file is only internal, the user must not
|
|
// handle conflicts for this
|
|
return null;
|
|
}
|
|
const remoteFile = filesMetadata[filePath];
|
|
const localFile = this.metadataStore.data.files[filePath];
|
|
if (remoteFile.deleted && localFile.deleted) {
|
|
return null;
|
|
}
|
|
const actualLocalSHA = await this.calculateSHA(filePath);
|
|
const remoteFileHasBeenModifiedSinceLastSync =
|
|
remoteFile.sha !== localFile.sha;
|
|
const localFileHasBeenModifiedSinceLastSync =
|
|
actualLocalSHA !== localFile.sha;
|
|
// This is an unlikely case. If the user manually edits
|
|
// the local file so that's identical to the remote one,
|
|
// but the local metadata SHA is different we don't want
|
|
// to show a conflict.
|
|
// Since that would show two identical files.
|
|
// Checking for this prevents showing a non conflict to the user.
|
|
const actualFilesAreDifferent = remoteFile.sha !== actualLocalSHA;
|
|
if (
|
|
remoteFileHasBeenModifiedSinceLastSync &&
|
|
localFileHasBeenModifiedSinceLastSync &&
|
|
actualFilesAreDifferent
|
|
) {
|
|
return filePath;
|
|
}
|
|
return null;
|
|
}),
|
|
);
|
|
|
|
return await Promise.all(
|
|
conflicts
|
|
.filter((filePath): filePath is string => filePath !== null)
|
|
.map(async (filePath: string) => {
|
|
// Load contents in parallel
|
|
const [remoteContent, localContent] = await Promise.all([
|
|
await (async () => {
|
|
const res = await this.client.getBlob(
|
|
filesMetadata[filePath].sha!,
|
|
);
|
|
return decodeBase64String(res.content);
|
|
})(),
|
|
await this.vault.adapter.read(normalizePath(filePath)),
|
|
]);
|
|
return {
|
|
filePath,
|
|
remoteContent,
|
|
localContent,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Determines which sync action to take for each file.
|
|
*
|
|
* @param remoteFiles All files in the remote repo
|
|
* @param localFiles All files in the local vault
|
|
* @param conflictFiles List of paths to files that have conflict with remote
|
|
*
|
|
* @returns List of SyncActions
|
|
*/
|
|
async determineSyncActions(
|
|
remoteFiles: { [key: string]: FileMetadata },
|
|
localFiles: { [key: string]: FileMetadata },
|
|
conflictFiles: string[],
|
|
) {
|
|
let actions: SyncAction[] = [];
|
|
|
|
const commonFiles = Object.keys(remoteFiles)
|
|
.filter((filePath) => filePath in localFiles)
|
|
// Remove conflicting files, we determine their actions in a different way
|
|
.filter((filePath) => !conflictFiles.contains(filePath));
|
|
|
|
// Get diff for common files
|
|
await Promise.all(
|
|
commonFiles.map(async (filePath: string) => {
|
|
if (filePath === `${this.vault.configDir}/${MANIFEST_FILE_NAME}`) {
|
|
// The manifest file must never trigger any action
|
|
return;
|
|
}
|
|
|
|
const remoteFile = remoteFiles[filePath];
|
|
const localFile = localFiles[filePath];
|
|
if (remoteFile.deleted && localFile.deleted) {
|
|
// Nothing to do
|
|
return;
|
|
}
|
|
|
|
const localSHA = await this.calculateSHA(filePath);
|
|
if (remoteFile.sha === localSHA) {
|
|
// If the remote file sha is identical to the actual sha of the local file
|
|
// there are no actions to take.
|
|
// We calculate the SHA at the moment instead of using the one stored in the
|
|
// metadata file cause we update that only when the file is uploaded or downloaded.
|
|
return;
|
|
}
|
|
|
|
if (remoteFile.deleted && !localFile.deleted) {
|
|
if ((remoteFile.deletedAt as number) > localFile.lastModified) {
|
|
actions.push({
|
|
type: "delete_local",
|
|
filePath: filePath,
|
|
});
|
|
} else if (
|
|
localFile.lastModified > (remoteFile.deletedAt as number)
|
|
) {
|
|
actions.push({ type: "upload", filePath: filePath });
|
|
}
|
|
}
|
|
|
|
if (!remoteFile.deleted && localFile.deleted) {
|
|
if (remoteFile.lastModified > (localFile.deletedAt as number)) {
|
|
actions.push({ type: "download", filePath: filePath });
|
|
} else if (
|
|
(localFile.deletedAt as number) > remoteFile.lastModified
|
|
) {
|
|
actions.push({
|
|
type: "delete_remote",
|
|
filePath: filePath,
|
|
});
|
|
}
|
|
}
|
|
|
|
// For non-deletion cases, if SHAs differ, we just need to check if local changed.
|
|
// Conflicts are already filtered out so we can make this decision easily
|
|
if (localSHA !== localFile.sha) {
|
|
actions.push({ type: "upload", filePath: filePath });
|
|
} else {
|
|
actions.push({ type: "download", filePath: filePath });
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Get diff for files in remote but not in local
|
|
Object.keys(remoteFiles).forEach((filePath: string) => {
|
|
const remoteFile = remoteFiles[filePath];
|
|
const localFile = localFiles[filePath];
|
|
if (localFile) {
|
|
// Local file exists, we already handled it.
|
|
// Skip it.
|
|
return;
|
|
}
|
|
if (remoteFile.deleted) {
|
|
// Remote is deleted but we don't have it locally.
|
|
// Nothing to do.
|
|
// TODO: Maybe we need to remove remote reference too?
|
|
} else {
|
|
actions.push({ type: "download", filePath: filePath });
|
|
}
|
|
});
|
|
|
|
// Get diff for files in local but not in remote
|
|
Object.keys(localFiles).forEach((filePath: string) => {
|
|
const remoteFile = remoteFiles[filePath];
|
|
const localFile = localFiles[filePath];
|
|
if (remoteFile) {
|
|
// Remote file exists, we already handled it.
|
|
// Skip it.
|
|
return;
|
|
}
|
|
if (localFile.deleted) {
|
|
// Local is deleted and remote doesn't exist.
|
|
// Just remove the local reference.
|
|
} else {
|
|
actions.push({ type: "upload", filePath: filePath });
|
|
}
|
|
});
|
|
|
|
if (!this.settings.syncConfigDir) {
|
|
// Remove all actions that involve the config directory if the user doesn't want to sync it.
|
|
// The manifest file is always synced.
|
|
return actions.filter((action: SyncAction) => {
|
|
return (
|
|
!action.filePath.startsWith(this.vault.configDir) ||
|
|
action.filePath === `${this.vault.configDir}/${MANIFEST_FILE_NAME}`
|
|
);
|
|
});
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
/**
|
|
* Calculates the SHA1 of a file given its content.
|
|
* This is the same identical algoritm used by git to calculate
|
|
* a blob's SHA.
|
|
* @param filePath normalized path to file
|
|
* @returns String containing the file SHA1
|
|
*/
|
|
async calculateSHA(filePath: string): Promise<string> {
|
|
const content = await this.vault.adapter.read(filePath);
|
|
const contentBytes = new TextEncoder().encode(content);
|
|
const header = new TextEncoder().encode(`blob ${contentBytes.length}\0`);
|
|
const store = new Uint8Array([...header, ...contentBytes]);
|
|
return await crypto.subtle.digest("SHA-1", store).then((hash) =>
|
|
Array.from(new Uint8Array(hash))
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join(""),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates a new sync commit in the remote repository.
|
|
*
|
|
* @param treeFiles Updated list of files in the remote tree
|
|
* @param baseTreeSha sha of the tree to use as base for the new tree
|
|
* @param conflictResolutions list of conflicts between remote and local files
|
|
*/
|
|
async commitSync(
|
|
treeFiles: { [key: string]: NewTreeRequestItem },
|
|
baseTreeSha: string,
|
|
conflictResolutions: ConflictResolution[] = [],
|
|
) {
|
|
// Update local sync time
|
|
const syncTime = Date.now();
|
|
this.metadataStore.data.lastSync = syncTime;
|
|
this.metadataStore.save();
|
|
|
|
// We update the last modified timestamp for all files that had resolved conflicts
|
|
// to the the same time as the sync time.
|
|
// At this time we still have not written the conflict resolution content to file,
|
|
// so the last modified timestamp doesn't reflect that.
|
|
// To prevent further conflicts in future syncs and to reflect the content change
|
|
// on the remote metadata we update the timestamp for the conflicting files here,
|
|
// just before pushing to remote.
|
|
// We're going to update the local content when the sync is successful.
|
|
conflictResolutions.forEach((resolution) => {
|
|
this.metadataStore.data.files[resolution.filePath].lastModified =
|
|
syncTime;
|
|
});
|
|
|
|
// We want the remote metadata file to track the correct SHA for each file blob,
|
|
// so just before we upload any file we update all their SHAs in the metadata file.
|
|
// This also makes it easier to handle conflicts.
|
|
// We don't save the metadata file after setting the SHAs cause we do that when
|
|
// the sync is fully commited at the end.
|
|
// TODO: Understand whether it's a problem we don't revert the SHA setting in case of sync failure
|
|
await Promise.all(
|
|
Object.keys(treeFiles)
|
|
.filter((filePath: string) => treeFiles[filePath].content)
|
|
.map(async (filePath: string) => {
|
|
const newSha = await this.calculateSHA(filePath);
|
|
this.metadataStore.data.files[filePath].sha = newSha;
|
|
}),
|
|
);
|
|
|
|
// Update manifest in list of new tree items
|
|
delete treeFiles[`${this.vault.configDir}/${MANIFEST_FILE_NAME}`].sha;
|
|
treeFiles[`${this.vault.configDir}/${MANIFEST_FILE_NAME}`].content =
|
|
JSON.stringify(this.metadataStore.data);
|
|
|
|
// Create the new tree
|
|
const newTree: { tree: NewTreeRequestItem[]; base_tree: string } = {
|
|
tree: Object.keys(treeFiles).map(
|
|
(filePath: string) => treeFiles[filePath],
|
|
),
|
|
base_tree: baseTreeSha,
|
|
};
|
|
const newTreeSha = await this.client.createTree(newTree);
|
|
|
|
const branchHeadSha = await this.client.getBranchHeadSha();
|
|
|
|
const commitSha = await this.client.createCommit(
|
|
// TODO: Make this configurable or find a nicer commit message
|
|
"Sync",
|
|
newTreeSha,
|
|
branchHeadSha,
|
|
);
|
|
|
|
await this.client.updateBranchHead(commitSha);
|
|
|
|
// Update the local content of all files that had conflicts we resolved
|
|
await Promise.all(
|
|
conflictResolutions.map(async (resolution) => {
|
|
await this.vault.adapter.write(resolution.filePath, resolution.content);
|
|
// Even though we set the last modified timestamp for all files with conflicts
|
|
// just before pushing the changes to remote we do it here again because the
|
|
// write right above would overwrite that.
|
|
// Since we want to keep the sync timestamp for this file to avoid future conflicts
|
|
// we update it again.
|
|
this.metadataStore.data.files[resolution.filePath].lastModified =
|
|
syncTime;
|
|
}),
|
|
);
|
|
// Now that the sync is done and we updated the content for conflicting files
|
|
// we can save the latest metadata to disk.
|
|
this.metadataStore.save();
|
|
await this.logger.info("Sync done");
|
|
}
|
|
|
|
async downloadFile(file: GetTreeResponseItem, lastModified: number) {
|
|
const fileMetadata = this.metadataStore.data.files[file.path];
|
|
if (fileMetadata && fileMetadata.sha === file.sha) {
|
|
// File already exists and has the same SHA, no need to download it again.
|
|
return;
|
|
}
|
|
const blob = await this.client.getBlob(file.sha);
|
|
const normalizedPath = normalizePath(file.path);
|
|
const fileFolder = normalizePath(
|
|
normalizedPath.split("/").slice(0, -1).join("/"),
|
|
);
|
|
if (!(await this.vault.adapter.exists(fileFolder))) {
|
|
await this.vault.adapter.mkdir(fileFolder);
|
|
}
|
|
await this.vault.adapter.writeBinary(
|
|
normalizedPath,
|
|
base64ToArrayBuffer(blob.content),
|
|
);
|
|
this.metadataStore.data.files[file.path] = {
|
|
path: file.path,
|
|
sha: file.sha,
|
|
dirty: false,
|
|
justDownloaded: true,
|
|
lastModified: lastModified,
|
|
};
|
|
await this.metadataStore.save();
|
|
}
|
|
|
|
async deleteLocalFile(filePath: string) {
|
|
const normalizedPath = normalizePath(filePath);
|
|
await this.vault.adapter.remove(normalizedPath);
|
|
this.metadataStore.data.files[filePath].deleted = true;
|
|
this.metadataStore.data.files[filePath].deletedAt = Date.now();
|
|
this.metadataStore.save();
|
|
}
|
|
|
|
async loadMetadata() {
|
|
await this.logger.info("Loading metadata");
|
|
await this.metadataStore.load();
|
|
if (Object.keys(this.metadataStore.data.files).length === 0) {
|
|
await this.logger.info("Metadata was empty, loading all files");
|
|
let files = [];
|
|
let folders = [this.vault.getRoot().path];
|
|
while (folders.length > 0) {
|
|
const folder = folders.pop();
|
|
if (folder === undefined) {
|
|
continue;
|
|
}
|
|
if (!this.settings.syncConfigDir && folder === this.vault.configDir) {
|
|
await this.logger.info("Skipping config dir");
|
|
// Skip the config dir if the user doesn't want to sync it
|
|
continue;
|
|
}
|
|
const res = await this.vault.adapter.list(folder);
|
|
files.push(...res.files);
|
|
folders.push(...res.folders);
|
|
}
|
|
files.forEach((filePath: string) => {
|
|
if (filePath === `${this.vault.configDir}/workspace.json`) {
|
|
// Obsidian recommends not syncing the workspace file
|
|
return;
|
|
}
|
|
|
|
this.metadataStore.data.files[filePath] = {
|
|
path: filePath,
|
|
sha: null,
|
|
dirty: false,
|
|
justDownloaded: false,
|
|
lastModified: Date.now(),
|
|
};
|
|
});
|
|
|
|
// Must be the first time we run, initialize the metadata store
|
|
// with itself and all files in the vault.
|
|
this.metadataStore.data.files[
|
|
`${this.vault.configDir}/${MANIFEST_FILE_NAME}`
|
|
] = {
|
|
path: `${this.vault.configDir}/${MANIFEST_FILE_NAME}`,
|
|
sha: null,
|
|
dirty: false,
|
|
justDownloaded: false,
|
|
lastModified: Date.now(),
|
|
};
|
|
this.metadataStore.save();
|
|
}
|
|
await this.logger.info("Loaded metadata");
|
|
}
|
|
|
|
/**
|
|
* Add all the files in the config dir in the metadata store.
|
|
* This is mainly useful when the user changes the sync config settings
|
|
* as we need to add those files to the metadata store or they would never be synced.
|
|
*/
|
|
async addConfigDirToMetadata() {
|
|
await this.logger.info("Adding config dir to metadata");
|
|
// Get all the files in the config dir
|
|
let files = [];
|
|
let folders = [this.vault.configDir];
|
|
while (folders.length > 0) {
|
|
const folder = folders.pop();
|
|
if (folder === undefined) {
|
|
continue;
|
|
}
|
|
const res = await this.vault.adapter.list(folder);
|
|
files.push(...res.files);
|
|
folders.push(...res.folders);
|
|
}
|
|
// Add them to the metadata store
|
|
files.forEach((filePath: string) => {
|
|
this.metadataStore.data.files[filePath] = {
|
|
path: filePath,
|
|
sha: null,
|
|
dirty: false,
|
|
justDownloaded: false,
|
|
lastModified: Date.now(),
|
|
};
|
|
});
|
|
this.metadataStore.save();
|
|
}
|
|
|
|
/**
|
|
* Remove all the files in the config dir from the metadata store.
|
|
* The metadata file is not removed as it must always be present.
|
|
* This is mainly useful when the user changes the sync config settings
|
|
* as we need to remove those files to the metadata store or they would
|
|
* keep being synced.
|
|
*/
|
|
async removeConfigDirFromMetadata() {
|
|
await this.logger.info("Removing config dir from metadata");
|
|
// Get all the files in the config dir
|
|
let files = [];
|
|
let folders = [this.vault.configDir];
|
|
while (folders.length > 0) {
|
|
const folder = folders.pop();
|
|
if (folder === undefined) {
|
|
continue;
|
|
}
|
|
const res = await this.vault.adapter.list(folder);
|
|
files.push(...res.files);
|
|
folders.push(...res.folders);
|
|
}
|
|
|
|
// Remove all them from the metadata store
|
|
files.forEach((filePath: string) => {
|
|
if (filePath === `${this.vault.configDir}/${MANIFEST_FILE_NAME}`) {
|
|
// We don't want to remove the metadata file even if it's in the config dir
|
|
return;
|
|
}
|
|
delete this.metadataStore.data.files[filePath];
|
|
});
|
|
this.metadataStore.save();
|
|
}
|
|
|
|
getFileMetadata(filePath: string): FileMetadata {
|
|
return this.metadataStore.data.files[filePath];
|
|
}
|
|
|
|
async startEventsListener() {
|
|
this.eventsListener.start();
|
|
}
|
|
|
|
/**
|
|
* Starts a new sync interval.
|
|
* Raises an error if the interval is already running.
|
|
*/
|
|
startSyncInterval(minutes: number): number {
|
|
if (this.syncIntervalId) {
|
|
throw new Error("Sync interval is already running");
|
|
}
|
|
this.syncIntervalId = window.setInterval(
|
|
async () => await this.sync(),
|
|
// Sync interval is set in minutes but setInterval expects milliseconds
|
|
minutes * 60 * 1000,
|
|
);
|
|
return this.syncIntervalId;
|
|
}
|
|
|
|
/**
|
|
* Stops the currently running sync interval
|
|
*/
|
|
stopSyncInterval() {
|
|
if (this.syncIntervalId) {
|
|
window.clearInterval(this.syncIntervalId);
|
|
this.syncIntervalId = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Util function that stops and restart the sync interval
|
|
*/
|
|
restartSyncInterval(minutes: number) {
|
|
this.stopSyncInterval();
|
|
return this.startSyncInterval(minutes);
|
|
}
|
|
}
|