2020-07-21 13:20:51 +00:00
|
|
|
import * as bluebird from 'bluebird';
|
|
|
|
import * as chokidar from 'chokidar';
|
|
|
|
import * as fse from 'fs-extra';
|
2022-07-04 14:14:55 +00:00
|
|
|
import moment from 'moment';
|
2020-07-21 13:20:51 +00:00
|
|
|
import * as path from 'path';
|
|
|
|
|
|
|
|
import {DocEntry, DocEntryTag} from 'app/common/DocListAPI';
|
2020-10-30 16:53:23 +00:00
|
|
|
import {DocSnapshots} from 'app/common/DocSnapshot';
|
2022-05-16 17:41:12 +00:00
|
|
|
import {DocumentUsage} from 'app/common/DocUsage';
|
2020-07-21 13:20:51 +00:00
|
|
|
import * as gutil from 'app/common/gutil';
|
2022-06-04 04:12:30 +00:00
|
|
|
import {Comm} from 'app/server/lib/Comm';
|
2020-07-21 13:20:51 +00:00
|
|
|
import * as docUtils from 'app/server/lib/docUtils';
|
|
|
|
import {GristServer} from 'app/server/lib/GristServer';
|
2024-07-01 14:24:16 +00:00
|
|
|
import {IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
|
2020-07-21 13:20:51 +00:00
|
|
|
import {IShell} from 'app/server/lib/IShell';
|
2022-07-04 14:14:55 +00:00
|
|
|
import log from 'app/server/lib/log';
|
|
|
|
import uuidv4 from "uuid/v4";
|
2020-07-21 13:20:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* DocStorageManager manages Grist documents. This implementation deals with files in the file
|
|
|
|
* system. An alternative implementation could provide the same public methods to implement
|
|
|
|
* storage management for the hosted version of Grist.
|
|
|
|
*
|
|
|
|
* This file-based DocStorageManager uses file path as the docName identifying a document, with
|
|
|
|
* one exception. For files in the docsRoot directory, the basename of the document is used
|
|
|
|
* instead, with .grist extension stripped; primarily to maintain previous behavior and keep
|
|
|
|
* clean-looking URLs. In all other cases, the realpath of the file (including .grist extension)
|
|
|
|
* is the canonical docName.
|
|
|
|
*
|
|
|
|
*/
|
|
|
|
export class DocStorageManager implements IDocStorageManager {
|
|
|
|
private _watcher: any; // chokidar filesystem watcher
|
|
|
|
private _shell: IShell;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize with the given root directory, which should be a fully-resolved path (i.e. using
|
|
|
|
* fs.realpath or docUtils.realPath).
|
|
|
|
* The file watcher is created if the optComm argument is given.
|
|
|
|
*/
|
|
|
|
constructor(private _docsRoot: string, private _samplesRoot?: string,
|
|
|
|
private _comm?: Comm, gristServer?: GristServer) {
|
|
|
|
// If we have a way to communicate with clients, watch the docsRoot for changes.
|
|
|
|
this._watcher = null;
|
2022-06-03 14:54:49 +00:00
|
|
|
this._shell = gristServer?.create.Shell?.() || {
|
2022-07-25 20:11:11 +00:00
|
|
|
trashItem() { throw new Error('Unable to move document to trash'); },
|
2020-07-21 13:20:51 +00:00
|
|
|
showItemInFolder() { throw new Error('Unable to show item in folder'); }
|
|
|
|
};
|
|
|
|
if (_comm) {
|
|
|
|
this._initFileWatcher();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the path to the given document. This is used by DocStorage.js, and is specific to the
|
|
|
|
* file-based storage implementation.
|
|
|
|
* @param {String} docName: The canonical docName.
|
|
|
|
* @returns {String} path: Filesystem path.
|
|
|
|
*/
|
|
|
|
public getPath(docName: string): string {
|
|
|
|
docName += (path.extname(docName) === '.grist' ? '' : '.grist');
|
|
|
|
return path.resolve(this._docsRoot, docName);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the path to the given sample document.
|
|
|
|
*/
|
|
|
|
public getSampleDocPath(sampleDocName: string): string|null {
|
|
|
|
return this._samplesRoot ? this.getPath(path.resolve(this._samplesRoot, sampleDocName)) : null;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Translates a possibly non-canonical docName to a canonical one (e.g. adds .grist to a path
|
|
|
|
* without .grist extension, and canonicalizes the path). All other functions deal with
|
|
|
|
* canonical docNames.
|
|
|
|
* @param {String} altDocName: docName which may not be the canonical one.
|
|
|
|
* @returns {Promise:String} Promise for the canonical docName.
|
|
|
|
*/
|
|
|
|
public async getCanonicalDocName(altDocName: string): Promise<string> {
|
|
|
|
const p = await docUtils.realPath(this.getPath(altDocName));
|
|
|
|
return path.dirname(p) === this._docsRoot ? path.basename(p, '.grist') : p;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Prepares a document for use locally. Returns whether the document is new (needs to be
|
|
|
|
* created). This is a no-op in the local DocStorageManager case.
|
|
|
|
*/
|
2021-01-12 15:48:40 +00:00
|
|
|
public async prepareLocalDoc(docName: string): Promise<boolean> { return false; }
|
2020-07-21 13:20:51 +00:00
|
|
|
|
2020-12-21 14:46:50 +00:00
|
|
|
public async prepareToCreateDoc(docName: string): Promise<void> {
|
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
2021-04-28 18:53:18 +00:00
|
|
|
public async prepareFork(srcDocName: string, destDocName: string): Promise<string> {
|
|
|
|
// This is implemented only to support old tests.
|
|
|
|
await fse.copy(this.getPath(srcDocName), this.getPath(destDocName));
|
|
|
|
return this.getPath(destDocName);
|
2021-01-12 15:48:40 +00:00
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* Returns a promise for the list of docNames to show in the doc list. For the file-based
|
|
|
|
* storage, this will include all .grist files under the docsRoot.
|
|
|
|
* @returns {Promise:Array<DocEntry>} Promise for an array of objects with `name`, `size`,
|
|
|
|
* and `mtime`.
|
|
|
|
*/
|
|
|
|
public listDocs(): Promise<DocEntry[]> {
|
|
|
|
return bluebird.Promise.all([
|
|
|
|
this._listDocs(this._docsRoot, ""),
|
|
|
|
this._samplesRoot ? this._listDocs(this._samplesRoot, "sample") : [],
|
|
|
|
])
|
|
|
|
.spread((docsEntries: DocEntry[], samplesEntries: DocEntry[]) => {
|
|
|
|
return [...docsEntries, ...samplesEntries];
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Deletes a document.
|
|
|
|
* @param {String} docName: docName of the document to delete.
|
|
|
|
* @returns {Promise} Resolved on success.
|
|
|
|
*/
|
2022-07-25 20:11:11 +00:00
|
|
|
public async deleteDoc(docName: string, deletePermanently?: boolean): Promise<void> {
|
2020-07-21 13:20:51 +00:00
|
|
|
const docPath = this.getPath(docName);
|
|
|
|
// Keep this check, to protect against wiping out the whole disk or the user's home.
|
|
|
|
if (path.extname(docPath) !== '.grist') {
|
|
|
|
return Promise.reject(new Error("Refusing to delete path which does not end in .grist"));
|
|
|
|
} else if (deletePermanently) {
|
2022-07-25 20:11:11 +00:00
|
|
|
await fse.remove(docPath);
|
2020-07-21 13:20:51 +00:00
|
|
|
} else {
|
2022-07-25 20:11:11 +00:00
|
|
|
await this._shell.trashItem(docPath);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Renames a closed document. In the file-system case, moves files to reflect the new paths. For
|
|
|
|
* a document already open, use `docStorageInstance.renameDocTo()` instead.
|
|
|
|
* @param {String} oldName: original docName.
|
|
|
|
* @param {String} newName: new docName.
|
|
|
|
* @returns {Promise} Resolved on success.
|
|
|
|
*/
|
|
|
|
public renameDoc(oldName: string, newName: string): Promise<void> {
|
|
|
|
const oldPath = this.getPath(oldName);
|
|
|
|
const newPath = this.getPath(newName);
|
|
|
|
return docUtils.createExclusive(newPath)
|
|
|
|
.catch(async (e: any) => {
|
|
|
|
if (e.code !== 'EEXIST') { throw e; }
|
|
|
|
const isSame = await docUtils.isSameFile(oldPath, newPath);
|
|
|
|
if (!isSame) { throw e; }
|
|
|
|
})
|
|
|
|
.then(() => fse.rename(oldPath, newPath))
|
|
|
|
// Send 'renameDocs' event immediately after the rename. Previously, this used to be sent by
|
|
|
|
// DocManager after reopening the renamed doc. The extra delay caused issue T407, where
|
|
|
|
// chokidar.watch() triggered 'removeDocs' before 'renameDocs'.
|
|
|
|
.then(() => { this._sendDocListAction('renameDocs', oldPath, [oldName, newName]); })
|
|
|
|
.catch((err: Error) => {
|
|
|
|
log.warn("DocStorageManager: rename %s -> %s failed: %s", oldPath, newPath, err.message);
|
|
|
|
throw err;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Should create a backup of the file
|
|
|
|
* @param {String} docName - docName to backup
|
|
|
|
* @param {String} backupTag - string to identify backup, like foo.grist.$DATE.$TAG.bak
|
|
|
|
* @returns {Promise} Resolved on success, returns path to backup (to show user)
|
|
|
|
*/
|
|
|
|
public makeBackup(docName: string, backupTag: string): Promise<string> {
|
|
|
|
// this need to persist between calling createNumbered and
|
|
|
|
// getting it's return value, to re-add the extension again (._.)
|
|
|
|
let ext: string;
|
|
|
|
let finalBakPath: string; // holds final value of path, with numbering
|
|
|
|
|
|
|
|
return bluebird.Promise.try(() => this._generateBackupFilePath(docName, backupTag))
|
|
|
|
.then((bakPath: string) => { // make a numbered migration if necessary
|
|
|
|
|
|
|
|
log.debug(`DocStorageManager: trying to make backup at ${bakPath}`);
|
|
|
|
|
|
|
|
// create a file at bakPath, adding numbers if necessary
|
|
|
|
ext = path.extname(bakPath); // persists to makeBackup closure
|
|
|
|
const bakPathPrefix = bakPath.slice(0, -ext.length);
|
|
|
|
return docUtils.createNumbered(bakPathPrefix, '-',
|
|
|
|
(pathPrefix: string) => docUtils.createExclusive(pathPrefix + ext)
|
|
|
|
);
|
|
|
|
}).tap((numberedBakPathPrefix: string) => { // do the copying, but return bakPath anyway
|
|
|
|
finalBakPath = numberedBakPathPrefix + ext;
|
|
|
|
const docPath = this.getPath(docName);
|
|
|
|
log.info(`Backing up ${docName} to ${finalBakPath}`);
|
|
|
|
return docUtils.copyFile(docPath, finalBakPath);
|
|
|
|
}).then(() => {
|
|
|
|
log.debug("DocStorageManager: Backup made successfully at: %s", finalBakPath);
|
|
|
|
return finalBakPath;
|
|
|
|
}).catch((err: Error) => {
|
|
|
|
log.error("DocStorageManager: Backup %s %s failed: %s", docName, err.message);
|
|
|
|
throw err;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Electron version only. Shows the given doc in the file explorer.
|
|
|
|
*/
|
|
|
|
public async showItemInFolder(docName: string): Promise<void> {
|
2021-04-26 21:54:09 +00:00
|
|
|
this._shell.showItemInFolder(this.getPath(docName));
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public async closeStorage() {
|
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
|
|
|
public async closeDocument(docName: string) {
|
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
|
|
|
public markAsChanged(docName: string): void {
|
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
|
|
|
public markAsEdited(docName: string): void {
|
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
2022-05-16 17:41:12 +00:00
|
|
|
public scheduleUsageUpdate(
|
|
|
|
docName: string,
|
|
|
|
docUsage: DocumentUsage,
|
|
|
|
minimizeDelay = false
|
|
|
|
): void {
|
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
public testReopenStorage(): void {
|
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
2020-12-21 14:46:50 +00:00
|
|
|
public async addToStorage(id: string): Promise<void> {
|
2020-07-21 13:20:51 +00:00
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
|
|
|
public prepareToCloseStorage(): void {
|
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
|
|
|
public async flushDoc(docName: string): Promise<void> {
|
|
|
|
// nothing to do
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getCopy(docName: string): Promise<string> {
|
|
|
|
const srcPath = this.getPath(docName);
|
|
|
|
const postfix = uuidv4();
|
|
|
|
const tmpPath = `${srcPath}-${postfix}`;
|
|
|
|
await docUtils.copyFile(srcPath, tmpPath);
|
|
|
|
return tmpPath;
|
|
|
|
}
|
|
|
|
|
2020-12-18 17:37:16 +00:00
|
|
|
public async getSnapshots(docName: string, skipMetadataCache?: boolean): Promise<DocSnapshots> {
|
2020-07-21 13:20:51 +00:00
|
|
|
throw new Error('getSnapshots not implemented');
|
|
|
|
}
|
|
|
|
|
2020-12-18 17:37:16 +00:00
|
|
|
public removeSnapshots(docName: string, snapshotIds: string[]): Promise<void> {
|
|
|
|
throw new Error('removeSnapshots not implemented');
|
|
|
|
}
|
|
|
|
|
2024-07-01 14:24:16 +00:00
|
|
|
public getSnapshotProgress(): SnapshotProgress {
|
2024-07-02 10:52:57 +00:00
|
|
|
return {
|
|
|
|
pushes: 0,
|
|
|
|
skippedPushes: 0,
|
|
|
|
errors: 0,
|
|
|
|
changes: 0,
|
|
|
|
windowsStarted: 0,
|
|
|
|
windowsDone: 0,
|
|
|
|
};
|
2024-07-01 14:24:16 +00:00
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
public async replace(docName: string, options: any): Promise<void> {
|
|
|
|
throw new Error('replacement not implemented');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a promise for the list of docNames for all docs in the given directory.
|
|
|
|
* @returns {Promise:Array<Object>} Promise for an array of objects with `name`, `size`,
|
|
|
|
* and `mtime`.
|
|
|
|
*/
|
|
|
|
private _listDocs(dirPath: string, tag: DocEntryTag): Promise<any[]> {
|
|
|
|
return fse.readdir(dirPath)
|
|
|
|
// Filter out for .grist files, and strip the .grist extension.
|
|
|
|
.then(entries => Promise.all(
|
|
|
|
entries.filter(e => (path.extname(e) === '.grist'))
|
|
|
|
.map(e => {
|
|
|
|
const docPath = path.resolve(dirPath, e);
|
|
|
|
return fse.stat(docPath)
|
|
|
|
.then(stat => getDocListFileInfo(docPath, stat, tag));
|
|
|
|
})
|
|
|
|
))
|
|
|
|
// Sort case-insensitively.
|
|
|
|
.then(entries => entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())))
|
|
|
|
// If the root directory is missing, just return an empty array.
|
|
|
|
.catch(err => {
|
|
|
|
if (err.cause && err.cause.code === 'ENOENT') { return []; }
|
|
|
|
throw err;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Generates the filename for the given document backup
|
|
|
|
* Backup names should look roughly like:
|
|
|
|
* ${basefilename}.grist.${YYYY-MM-DD}.${tag}.bak
|
|
|
|
*
|
|
|
|
* @returns {Promise} backup filepath (might need to createNumbered)
|
|
|
|
*/
|
|
|
|
private _generateBackupFilePath(docName: string, backupTag: string): Promise<string> {
|
|
|
|
const dateString = moment().format("YYYY-MM-DD");
|
|
|
|
|
|
|
|
return docUtils.realPath(this.getPath(docName))
|
|
|
|
.then((filePath: string) => {
|
|
|
|
const fileName = path.basename(filePath);
|
|
|
|
const fileDir = path.dirname(filePath);
|
|
|
|
|
|
|
|
const bakName = `${fileName}.${dateString}.${backupTag}.bak`;
|
|
|
|
return path.join(fileDir, bakName);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates the file watcher and begins monitoring the docsRoot. Returns the created watcher.
|
|
|
|
*/
|
|
|
|
private _initFileWatcher(): void {
|
|
|
|
// NOTE: The chokidar watcher reports file renames as unlink then add events.
|
|
|
|
this._watcher = chokidar.watch(this._docsRoot, {
|
|
|
|
ignoreInitial: true, // Prevent messages for initial adds of all docs when watching begins
|
|
|
|
depth: 0, // Ignore changes in subdirectories of docPath
|
|
|
|
alwaysStat: true, // Tells the watcher to always include the stats arg
|
|
|
|
// Waits for a file to remain constant for a short time after changing before triggering
|
|
|
|
// an action. Prevents reporting of incomplete writes.
|
|
|
|
awaitWriteFinish: {
|
|
|
|
stabilityThreshold: 100, // Waits for the file to remain constant for 100ms
|
|
|
|
pollInterval: 10 // Polls the file every 10ms after a change
|
|
|
|
}
|
|
|
|
});
|
|
|
|
this._watcher.on('add', (docPath: string, fsStats: any) => {
|
|
|
|
this._sendDocListAction('addDocs', docPath, getDocListFileInfo(docPath, fsStats, ""));
|
|
|
|
});
|
|
|
|
this._watcher.on('change', (docPath: string, fsStats: any) => {
|
|
|
|
this._sendDocListAction('changeDocs', docPath, getDocListFileInfo(docPath, fsStats, ""));
|
|
|
|
});
|
|
|
|
this._watcher.on('unlink', (docPath: string) => {
|
|
|
|
this._sendDocListAction('removeDocs', docPath, getDocName(docPath));
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Helper to broadcast a docListAction for a single doc to clients. If the action is not on a
|
|
|
|
* '.grist' file, it is not sent.
|
|
|
|
* @param {String} actionType - DocListAction type to send, 'addDocs' | 'removeDocs' | 'changeDocs'.
|
|
|
|
* @param {String} docPath - System path to the doc including the filename.
|
|
|
|
* @param {Any} data - Data to send as the message.
|
|
|
|
*/
|
|
|
|
private _sendDocListAction(actionType: string, docPath: string, data: any): void {
|
|
|
|
if (this._comm && gutil.endsWith(docPath, '.grist')) {
|
|
|
|
log.debug(`Sending ${actionType} action for doc ${getDocName(docPath)}`);
|
|
|
|
this._comm.broadcastMessage('docListAction', { [actionType]: [data] });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Helper to return the docname (without .grist) given the path to the .grist file.
|
|
|
|
*/
|
|
|
|
function getDocName(docPath: string): string {
|
|
|
|
return path.basename(docPath, '.grist');
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Helper to get the stats used by the Grist DocList for a document.
|
|
|
|
* @param {String} docPath - System path to the doc including the doc filename.
|
|
|
|
* @param {Object} fsStat - fs.Stats object describing the file metadata.
|
|
|
|
* @param {String} tag - The tag indicating the type of doc.
|
|
|
|
* @return {Promise:Object} Promise for an object containing stats for the requested doc.
|
|
|
|
*/
|
|
|
|
function getDocListFileInfo(docPath: string, fsStat: any, tag: DocEntryTag): DocEntry {
|
|
|
|
return {
|
|
|
|
docId: undefined, // TODO: Should include docId if it exists
|
|
|
|
name: getDocName(docPath),
|
|
|
|
mtime: fsStat.mtime,
|
|
|
|
size: fsStat.size,
|
|
|
|
tag
|
|
|
|
};
|
|
|
|
}
|