feat: restore files from trash
This commit is contained in:
parent
e5bdaec0aa
commit
cc0c050985
|
|
@ -272,6 +272,12 @@ export function createPluginAPI(pluginId) {
|
||||||
return App.ListVaultTrash(pluginId);
|
return App.ListVaultTrash(pluginId);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
restoreTrash: function(trashId, options) {
|
||||||
|
assertActive('files.restoreTrash(' + trashId + ')');
|
||||||
|
return callBackend(pluginId, 'files.restoreTrash(' + trashId + ')', function() {
|
||||||
|
return App.RestoreVaultTrash(pluginId, trashId, options || {});
|
||||||
|
});
|
||||||
|
},
|
||||||
openExternal: function(relativePath) {
|
openExternal: function(relativePath) {
|
||||||
assertActive('files.openExternal(' + relativePath + ')');
|
assertActive('files.openExternal(' + relativePath + ')');
|
||||||
return callBackendErrorString(pluginId, 'files.openExternal(' + relativePath + ')', function() {
|
return callBackendErrorString(pluginId, 'files.openExternal(' + relativePath + ')', function() {
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@
|
||||||
var vaultFiles = makeDefaultVaultFiles();
|
var vaultFiles = makeDefaultVaultFiles();
|
||||||
var externalOpens = [];
|
var externalOpens = [];
|
||||||
var trashEntries = [];
|
var trashEntries = [];
|
||||||
|
var trashPayloads = {};
|
||||||
window.__wailsMockExternalOpens = [];
|
window.__wailsMockExternalOpens = [];
|
||||||
var workspaceTree = makeDefaultWorkspaceTree();
|
var workspaceTree = makeDefaultWorkspaceTree();
|
||||||
var reloadResponseMode = 'tuple';
|
var reloadResponseMode = 'tuple';
|
||||||
|
|
@ -1345,6 +1346,9 @@
|
||||||
var trashPath = '.verstak/trash/files/' + trashId + '/' + baseName(norm.path);
|
var trashPath = '.verstak/trash/files/' + trashId + '/' + baseName(norm.path);
|
||||||
var originalType = vaultFiles[norm.path].type || 'file';
|
var originalType = vaultFiles[norm.path].type || 'file';
|
||||||
var moving = Object.keys(vaultFiles).filter(function (path) { return path === norm.path || path.indexOf(norm.path + '/') === 0; });
|
var moving = Object.keys(vaultFiles).filter(function (path) { return path === norm.path || path.indexOf(norm.path + '/') === 0; });
|
||||||
|
trashPayloads[trashId] = moving.map(function (path) {
|
||||||
|
return { suffix: path.slice(norm.path.length), entry: Object.assign({}, vaultFiles[path]) };
|
||||||
|
});
|
||||||
moving.forEach(function (path) { delete vaultFiles[path]; });
|
moving.forEach(function (path) { delete vaultFiles[path]; });
|
||||||
var entry = { originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString(), originalType: originalType, basename: baseName(norm.path) };
|
var entry = { originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString(), originalType: originalType, basename: baseName(norm.path) };
|
||||||
trashEntries.unshift(entry);
|
trashEntries.unshift(entry);
|
||||||
|
|
@ -1355,6 +1359,29 @@
|
||||||
if (err) return Promise.resolve([[], err]);
|
if (err) return Promise.resolve([[], err]);
|
||||||
return Promise.resolve([trashEntries.slice(), '']);
|
return Promise.resolve([trashEntries.slice(), '']);
|
||||||
},
|
},
|
||||||
|
RestoreVaultTrash: function (pluginId, trashId, options) {
|
||||||
|
var deleteErr = requirePluginPermission(pluginId, 'files.delete');
|
||||||
|
if (deleteErr) return Promise.resolve(['', deleteErr]);
|
||||||
|
var writeErr = requirePluginPermission(pluginId, 'files.write');
|
||||||
|
if (writeErr) return Promise.resolve(['', writeErr]);
|
||||||
|
options = options || {};
|
||||||
|
var entry = trashEntries.find(function (item) { return item.trashId === trashId; });
|
||||||
|
if (!entry) return Promise.resolve(['', 'not-found: trash entry ' + trashId]);
|
||||||
|
var target = normalizeVaultPath(options.targetPath || entry.originalPath, false);
|
||||||
|
if (target.error) return Promise.resolve(['', target.error]);
|
||||||
|
if (vaultFiles[target.path] && !options.overwrite) return Promise.resolve(['', 'conflict: ' + target.path]);
|
||||||
|
var parent = parentPath(target.path);
|
||||||
|
if (!vaultFiles[parent] || vaultFiles[parent].type !== 'folder') return Promise.resolve(['', 'parent-not-found: ' + parent]);
|
||||||
|
if (options.overwrite) {
|
||||||
|
Object.keys(vaultFiles).filter(function (path) { return path === target.path || path.indexOf(target.path + '/') === 0; }).forEach(function (path) { delete vaultFiles[path]; });
|
||||||
|
}
|
||||||
|
(trashPayloads[trashId] || []).forEach(function (item) {
|
||||||
|
vaultFiles[target.path + item.suffix] = Object.assign({}, item.entry, { modifiedAt: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
delete trashPayloads[trashId];
|
||||||
|
trashEntries = trashEntries.filter(function (item) { return item.trashId !== trashId; });
|
||||||
|
return Promise.resolve([target.path, '']);
|
||||||
|
},
|
||||||
OpenVaultPathExternal: function (pluginId, relativePath) {
|
OpenVaultPathExternal: function (pluginId, relativePath) {
|
||||||
var err = requirePluginPermission(pluginId, 'files.openExternal');
|
var err = requirePluginPermission(pluginId, 'files.openExternal');
|
||||||
if (err) return Promise.resolve(err);
|
if (err) return Promise.resolve(err);
|
||||||
|
|
@ -1722,6 +1749,8 @@
|
||||||
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
|
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
|
||||||
vaultFiles = makeDefaultVaultFiles();
|
vaultFiles = makeDefaultVaultFiles();
|
||||||
externalOpens = [];
|
externalOpens = [];
|
||||||
|
trashEntries = [];
|
||||||
|
trashPayloads = {};
|
||||||
window.__wailsMockExternalOpens = [];
|
window.__wailsMockExternalOpens = [];
|
||||||
workspaceTree = makeDefaultWorkspaceTree();
|
workspaceTree = makeDefaultWorkspaceTree();
|
||||||
reloadResponseMode = 'tuple';
|
reloadResponseMode = 'tuple';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
globalThis.window = {
|
||||||
|
__VERSTAK_PLUGIN_REGISTRY__: {},
|
||||||
|
__VERSTAK_EVENT_HANDLERS__: {},
|
||||||
|
__VERSTAK_COMMAND_HANDLERS__: {},
|
||||||
|
go: {
|
||||||
|
api: {
|
||||||
|
App: {
|
||||||
|
RestoreVaultTrash: (pluginId, trashId, options) => {
|
||||||
|
calls.push({ pluginId, trashId, options });
|
||||||
|
return Promise.resolve(['Docs/restored.txt', '']);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.__mockApp = window.go.api.App;
|
||||||
|
|
||||||
|
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8')
|
||||||
|
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;');
|
||||||
|
const tempPath = path.resolve('/tmp/verstak-plugin-api-files-test.mjs');
|
||||||
|
fs.writeFileSync(tempPath, source);
|
||||||
|
|
||||||
|
const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now());
|
||||||
|
const api = apiModule.createPluginAPI('verstak.files');
|
||||||
|
|
||||||
|
if (!api.files || typeof api.files.restoreTrash !== 'function') {
|
||||||
|
throw new Error('api.files.restoreTrash is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const restored = await api.files.restoreTrash('trash-1', { overwrite: true });
|
||||||
|
if (restored !== 'Docs/restored.txt') {
|
||||||
|
throw new Error(`unexpected restore result: ${JSON.stringify(restored)}`);
|
||||||
|
}
|
||||||
|
if (calls.length !== 1 || calls[0].pluginId !== 'verstak.files' || calls[0].trashId !== 'trash-1' || calls[0].options.overwrite !== true) {
|
||||||
|
throw new Error(`unexpected RestoreVaultTrash call: ${JSON.stringify(calls)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('plugin api files smoke passed');
|
||||||
|
|
@ -66,6 +66,8 @@ export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Ent
|
||||||
|
|
||||||
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>;
|
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>;
|
||||||
|
|
||||||
|
export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>;
|
||||||
|
|
||||||
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
|
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
|
||||||
|
|
||||||
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
|
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
|
||||||
|
|
@ -106,6 +108,8 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise
|
||||||
|
|
||||||
export function ReloadPlugins():Promise<number|string>;
|
export function ReloadPlugins():Promise<number|string>;
|
||||||
|
|
||||||
|
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
|
||||||
|
|
||||||
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
|
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
@ -126,8 +130,6 @@ export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
|
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
|
||||||
|
|
||||||
export function ListVaultTrash(arg1:string):Promise<any[]|string>;
|
|
||||||
|
|
||||||
export function TrashWorkspace(arg1:string):Promise<workspace.TrashResult|string>;
|
export function TrashWorkspace(arg1:string):Promise<workspace.TrashResult|string>;
|
||||||
|
|
||||||
export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>;
|
export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>;
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,10 @@ export function ListVaultFiles(arg1, arg2) {
|
||||||
return window['go']['api']['App']['ListVaultFiles'](arg1, arg2);
|
return window['go']['api']['App']['ListVaultFiles'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListVaultTrash(arg1) {
|
||||||
|
return window['go']['api']['App']['ListVaultTrash'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ListWorkspaces() {
|
export function ListWorkspaces() {
|
||||||
return window['go']['api']['App']['ListWorkspaces']();
|
return window['go']['api']['App']['ListWorkspaces']();
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +202,10 @@ export function ReloadPlugins() {
|
||||||
return window['go']['api']['App']['ReloadPlugins']();
|
return window['go']['api']['App']['ReloadPlugins']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RestoreVaultTrash(arg1, arg2, arg3) {
|
||||||
|
return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function RenameWorkspace(arg1, arg2) {
|
export function RenameWorkspace(arg1, arg2) {
|
||||||
return window['go']['api']['App']['RenameWorkspace'](arg1, arg2);
|
return window['go']['api']['App']['RenameWorkspace'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
@ -238,10 +246,6 @@ export function TrashVaultPath(arg1, arg2) {
|
||||||
return window['go']['api']['App']['TrashVaultPath'](arg1, arg2);
|
return window['go']['api']['App']['TrashVaultPath'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListVaultTrash(arg1) {
|
|
||||||
return window['go']['api']['App']['ListVaultTrash'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TrashWorkspace(arg1) {
|
export function TrashWorkspace(arg1) {
|
||||||
return window['go']['api']['App']['TrashWorkspace'](arg1);
|
return window['go']['api']['App']['TrashWorkspace'](arg1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,51 @@
|
||||||
export namespace api {
|
export namespace api {
|
||||||
|
|
||||||
|
export class FlatContextMenuEntry {
|
||||||
|
pluginId: string;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
context: string;
|
||||||
|
group?: string;
|
||||||
|
capability?: string;
|
||||||
|
handler?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlatContextMenuEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.pluginId = source["pluginId"];
|
||||||
|
this.id = source["id"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.context = source["context"];
|
||||||
|
this.group = source["group"];
|
||||||
|
this.capability = source["capability"];
|
||||||
|
this.handler = source["handler"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class FlatAction {
|
||||||
|
pluginId: string;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
capability?: string;
|
||||||
|
handler?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlatAction(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.pluginId = source["pluginId"];
|
||||||
|
this.id = source["id"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.icon = source["icon"];
|
||||||
|
this.capability = source["capability"];
|
||||||
|
this.handler = source["handler"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class FlatWorkspaceItem {
|
export class FlatWorkspaceItem {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -80,24 +126,6 @@ export namespace api {
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class FlatSearchProvider {
|
|
||||||
pluginId: string;
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
handler: string;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new FlatSearchProvider(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.pluginId = source["pluginId"];
|
|
||||||
this.id = source["id"];
|
|
||||||
this.label = source["label"];
|
|
||||||
this.handler = source["handler"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class FlatStatusBarItem {
|
export class FlatStatusBarItem {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -160,6 +188,24 @@ export namespace api {
|
||||||
this.component = source["component"];
|
this.component = source["component"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class FlatSearchProvider {
|
||||||
|
pluginId: string;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
handler: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlatSearchProvider(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.pluginId = source["pluginId"];
|
||||||
|
this.id = source["id"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.handler = source["handler"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class FlatCommand {
|
export class FlatCommand {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -209,6 +255,9 @@ export namespace api {
|
||||||
statusBarItems: FlatStatusBarItem[];
|
statusBarItems: FlatStatusBarItem[];
|
||||||
openProviders: FlatOpenProvider[];
|
openProviders: FlatOpenProvider[];
|
||||||
workspaceItems: FlatWorkspaceItem[];
|
workspaceItems: FlatWorkspaceItem[];
|
||||||
|
fileActions: FlatAction[];
|
||||||
|
noteActions: FlatAction[];
|
||||||
|
contextMenuEntries: FlatContextMenuEntry[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ContributionSummary(source);
|
return new ContributionSummary(source);
|
||||||
|
|
@ -224,6 +273,9 @@ export namespace api {
|
||||||
this.statusBarItems = this.convertValues(source["statusBarItems"], FlatStatusBarItem);
|
this.statusBarItems = this.convertValues(source["statusBarItems"], FlatStatusBarItem);
|
||||||
this.openProviders = this.convertValues(source["openProviders"], FlatOpenProvider);
|
this.openProviders = this.convertValues(source["openProviders"], FlatOpenProvider);
|
||||||
this.workspaceItems = this.convertValues(source["workspaceItems"], FlatWorkspaceItem);
|
this.workspaceItems = this.convertValues(source["workspaceItems"], FlatWorkspaceItem);
|
||||||
|
this.fileActions = this.convertValues(source["fileActions"], FlatAction);
|
||||||
|
this.noteActions = this.convertValues(source["noteActions"], FlatAction);
|
||||||
|
this.contextMenuEntries = this.convertValues(source["contextMenuEntries"], FlatContextMenuEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
|
@ -252,6 +304,9 @@ export namespace api {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class SyncStatusDTO {
|
export class SyncStatusDTO {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
|
|
@ -390,6 +445,42 @@ export namespace files {
|
||||||
this.overwrite = source["overwrite"];
|
this.overwrite = source["overwrite"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class RestoreOptions {
|
||||||
|
targetPath?: string;
|
||||||
|
overwrite: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RestoreOptions(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.targetPath = source["targetPath"];
|
||||||
|
this.overwrite = source["overwrite"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class TrashEntry {
|
||||||
|
originalPath: string;
|
||||||
|
trashPath: string;
|
||||||
|
trashId: string;
|
||||||
|
deletedAt: string;
|
||||||
|
originalType: string;
|
||||||
|
basename: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new TrashEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.originalPath = source["originalPath"];
|
||||||
|
this.trashPath = source["trashPath"];
|
||||||
|
this.trashId = source["trashId"];
|
||||||
|
this.deletedAt = source["deletedAt"];
|
||||||
|
this.originalType = source["originalType"];
|
||||||
|
this.basename = source["basename"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class TrashResult {
|
export class TrashResult {
|
||||||
originalPath: string;
|
originalPath: string;
|
||||||
trashPath: string;
|
trashPath: string;
|
||||||
|
|
|
||||||
|
|
@ -987,6 +987,49 @@ func (a *App) ListVaultTrash(pluginID string) ([]corefiles.TrashEntry, string) {
|
||||||
return entries, ""
|
return entries, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestoreVaultTrash restores a file or folder from internal trash for a plugin with files.delete and files.write.
|
||||||
|
func (a *App) RestoreVaultTrash(pluginID, trashID string, options corefiles.RestoreOptions) (string, string) {
|
||||||
|
if _, err := a.requirePluginAccess(pluginID, "files.delete"); err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
if a.files == nil {
|
||||||
|
return "", "files service not initialized"
|
||||||
|
}
|
||||||
|
entries, err := a.files.ListTrashEntries()
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
var entry corefiles.TrashEntry
|
||||||
|
for _, candidate := range entries {
|
||||||
|
if candidate.TrashID == trashID {
|
||||||
|
entry = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry.TrashID == "" {
|
||||||
|
return "", "not-found: trash entry " + trashID
|
||||||
|
}
|
||||||
|
restoredPath, err := a.files.RestoreTrashEntry(trashID, options)
|
||||||
|
if err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
if err := a.recordFileSyncOp(syncEntityTypeForFileType(entry.OriginalType), restoredPath, syncsvc.OpCreate, map[string]string{
|
||||||
|
"path": restoredPath,
|
||||||
|
}); err != nil {
|
||||||
|
return "", err.Error()
|
||||||
|
}
|
||||||
|
a.publishFileActivity("file.changed", pluginID, restoredPath, map[string]interface{}{
|
||||||
|
"operation": syncsvc.OpCreate,
|
||||||
|
"type": string(entry.OriginalType),
|
||||||
|
"restored": true,
|
||||||
|
"trashId": trashID,
|
||||||
|
})
|
||||||
|
return restoredPath, ""
|
||||||
|
}
|
||||||
|
|
||||||
// OpenVaultPathExternal opens a vault-relative file or folder in the OS default app.
|
// OpenVaultPathExternal opens a vault-relative file or folder in the OS default app.
|
||||||
func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string {
|
func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string {
|
||||||
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
|
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,17 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
|
||||||
if len(trashEntries) != 1 || trashEntries[0].OriginalPath != "Docs/two.txt" || trashEntries[0].TrashID != trash.TrashID {
|
if len(trashEntries) != 1 || trashEntries[0].OriginalPath != "Docs/two.txt" || trashEntries[0].TrashID != trash.TrashID {
|
||||||
t.Fatalf("trash entries = %+v, want Docs/two.txt", trashEntries)
|
t.Fatalf("trash entries = %+v, want Docs/two.txt", trashEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restored, errStr := app.RestoreVaultTrash("files.plugin", trash.TrashID, corefiles.RestoreOptions{})
|
||||||
|
if errStr != "" {
|
||||||
|
t.Fatalf("RestoreVaultTrash: %s", errStr)
|
||||||
|
}
|
||||||
|
if restored != "Docs/two.txt" {
|
||||||
|
t.Fatalf("restored path = %q, want Docs/two.txt", restored)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Docs", "two.txt")); err != nil {
|
||||||
|
t.Fatalf("restored file missing: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilesBridgeWritePublishesFileChangedActivityEvent(t *testing.T) {
|
func TestFilesBridgeWritePublishesFileChangedActivityEvent(t *testing.T) {
|
||||||
|
|
@ -610,6 +621,24 @@ func TestFilesBridgePermissions(t *testing.T) {
|
||||||
call: func(app *App) string { _, errStr := app.TrashVaultPath("files.plugin", "one.txt"); return errStr },
|
call: func(app *App) string { _, errStr := app.TrashVaultPath("files.plugin", "one.txt"); return errStr },
|
||||||
wantPhrase: "files.delete",
|
wantPhrase: "files.delete",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "restore requires delete",
|
||||||
|
perms: []string{"files.read", "files.write"},
|
||||||
|
call: func(app *App) string {
|
||||||
|
_, errStr := app.RestoreVaultTrash("files.plugin", "missing", corefiles.RestoreOptions{})
|
||||||
|
return errStr
|
||||||
|
},
|
||||||
|
wantPhrase: "files.delete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "restore requires write",
|
||||||
|
perms: []string{"files.read", "files.delete"},
|
||||||
|
call: func(app *App) string {
|
||||||
|
_, errStr := app.RestoreVaultTrash("files.plugin", "missing", corefiles.RestoreOptions{})
|
||||||
|
return errStr
|
||||||
|
},
|
||||||
|
wantPhrase: "files.write",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "open external requires openExternal",
|
name: "open external requires openExternal",
|
||||||
perms: []string{"files.read", "files.write", "files.delete"},
|
perms: []string{"files.read", "files.write", "files.delete"},
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,143 @@ func (s *Service) ListTrashEntries() ([]TrashEntry, error) {
|
||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) RestoreTrashEntry(trashID string, options RestoreOptions) (string, error) {
|
||||||
|
root, err := s.vaultRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := validateTrashID(trashID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
entry, err := readTrashEntry(root, trashID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetRel := entry.OriginalPath
|
||||||
|
if options.TargetPath != "" {
|
||||||
|
targetRel = options.TargetPath
|
||||||
|
}
|
||||||
|
targetRel, err = NormalizeRelativeFile(targetRel)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
targetFull, err := s.resolve(root, targetRel)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := rejectSymlinkPath(root, targetRel, false); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
trashDir := filepath.Join(root, ".verstak", "trash", "files", trashID)
|
||||||
|
payloadFull := filepath.Join(root, filepath.FromSlash(entry.TrashPath))
|
||||||
|
if !isInsideDir(trashDir, payloadFull) {
|
||||||
|
return "", fmt.Errorf("invalid-trash-entry: payload outside trash")
|
||||||
|
}
|
||||||
|
if info, err := os.Lstat(payloadFull); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("not-found: trash payload %s", trashID)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
} else if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return "", fmt.Errorf("symlink-not-allowed: %s", entry.TrashPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := filepath.Dir(targetFull)
|
||||||
|
if info, err := os.Stat(parent); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("parent-not-found: %s", pathDir(targetRel))
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
} else if !info.IsDir() {
|
||||||
|
return "", fmt.Errorf("parent-not-directory: %s", pathDir(targetRel))
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, err := os.Lstat(targetFull); err == nil {
|
||||||
|
if existing.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return "", fmt.Errorf("symlink-not-allowed: %s", targetRel)
|
||||||
|
}
|
||||||
|
if !options.Overwrite {
|
||||||
|
return "", fmt.Errorf("conflict: %s", targetRel)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(targetFull); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
} else if err != nil && !os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(payloadFull, targetFull); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(trashDir); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return targetRel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTrashEntry(root, trashID string) (TrashEntry, error) {
|
||||||
|
if err := validateTrashID(trashID); err != nil {
|
||||||
|
return TrashEntry{}, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, ".verstak", "trash", "files", trashID, "metadata.json"))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return TrashEntry{}, fmt.Errorf("not-found: trash entry %s", trashID)
|
||||||
|
}
|
||||||
|
return TrashEntry{}, err
|
||||||
|
}
|
||||||
|
var raw struct {
|
||||||
|
OriginalPath string `json:"originalPath"`
|
||||||
|
TrashPath string `json:"trashPath"`
|
||||||
|
TrashID string `json:"trashId"`
|
||||||
|
DeletedAt string `json:"deletedAt"`
|
||||||
|
OriginalType string `json:"originalType"`
|
||||||
|
Basename string `json:"basename"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return TrashEntry{}, err
|
||||||
|
}
|
||||||
|
if raw.OriginalPath == "" || raw.TrashPath == "" || raw.TrashID == "" || raw.DeletedAt == "" {
|
||||||
|
return TrashEntry{}, fmt.Errorf("invalid-trash-entry: missing metadata")
|
||||||
|
}
|
||||||
|
if raw.TrashID != trashID {
|
||||||
|
return TrashEntry{}, fmt.Errorf("invalid-trash-entry: mismatched trash id")
|
||||||
|
}
|
||||||
|
return TrashEntry{
|
||||||
|
OriginalPath: raw.OriginalPath,
|
||||||
|
TrashPath: raw.TrashPath,
|
||||||
|
TrashID: raw.TrashID,
|
||||||
|
DeletedAt: raw.DeletedAt,
|
||||||
|
OriginalType: FileType(raw.OriginalType),
|
||||||
|
Basename: raw.Basename,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTrashID(trashID string) error {
|
||||||
|
if trashID == "" || trashID == "." || trashID == ".." || strings.ContainsAny(trashID, "/\\\x00") {
|
||||||
|
return fmt.Errorf("invalid-trash-id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInsideDir(parent, child string) bool {
|
||||||
|
absParent, err := filepath.Abs(parent)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
absChild, err := filepath.Abs(child)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(absParent, absChild)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) vaultRoot() (string, error) {
|
func (s *Service) vaultRoot() (string, error) {
|
||||||
if s == nil || s.vault == nil {
|
if s == nil || s.vault == nil {
|
||||||
return "", fmt.Errorf("vault-not-initialized")
|
return "", fmt.Errorf("vault-not-initialized")
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,96 @@ func TestListTrashEntriesReturnsMetadata(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestoreTrashEntryRestoresOriginalPathAndRemovesTrashMetadata(t *testing.T) {
|
||||||
|
s, root := newTestService(t)
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "Docs", "restore.txt"), []byte("restore me"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
trash, err := s.TrashVaultPath("Docs/restore.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TrashVaultPath: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := s.RestoreTrashEntry(trash.TrashID, RestoreOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RestoreTrashEntry: %v", err)
|
||||||
|
}
|
||||||
|
if restored != "Docs/restore.txt" {
|
||||||
|
t.Fatalf("restored path = %q, want Docs/restore.txt", restored)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, "Docs", "restore.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("restored file missing: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "restore me" {
|
||||||
|
t.Fatalf("restored content = %q", string(data))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, trash.TrashPath)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("trash payload should be removed, stat err = %v", err)
|
||||||
|
}
|
||||||
|
entries, err := s.ListTrashEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTrashEntries: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("trash entries after restore = %+v, want none", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreTrashEntryConflictAndOverwrite(t *testing.T) {
|
||||||
|
s, root := newTestService(t)
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "conflict.txt"), []byte("trashed"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
trash, err := s.TrashVaultPath("conflict.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TrashVaultPath: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "conflict.txt"), []byte("existing"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.RestoreTrashEntry(trash.TrashID, RestoreOptions{}); err == nil || !strings.Contains(err.Error(), "conflict: conflict.txt") {
|
||||||
|
t.Fatalf("restore conflict error = %v, want conflict", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, "conflict.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != "existing" {
|
||||||
|
t.Fatalf("conflicting file content = %q, want existing", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := s.RestoreTrashEntry(trash.TrashID, RestoreOptions{Overwrite: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RestoreTrashEntry overwrite: %v", err)
|
||||||
|
}
|
||||||
|
if restored != "conflict.txt" {
|
||||||
|
t.Fatalf("restored path = %q, want conflict.txt", restored)
|
||||||
|
}
|
||||||
|
data, err = os.ReadFile(filepath.Join(root, "conflict.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != "trashed" {
|
||||||
|
t.Fatalf("overwritten content = %q, want trashed", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreTrashEntryRejectsInvalidTrashID(t *testing.T) {
|
||||||
|
s, _ := newTestService(t)
|
||||||
|
for _, trashID := range []string{"", "../escape", "bad/slash"} {
|
||||||
|
t.Run(trashID, func(t *testing.T) {
|
||||||
|
if _, err := s.RestoreTrashEntry(trashID, RestoreOptions{}); err == nil {
|
||||||
|
t.Fatal("expected invalid trash id error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSymlinkEscapeRejected(t *testing.T) {
|
func TestSymlinkEscapeRejected(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
t.Skip("symlink creation requires privileges on many Windows test environments")
|
t.Skip("symlink creation requires privileges on many Windows test environments")
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ type MoveOptions struct {
|
||||||
Overwrite bool `json:"overwrite"`
|
Overwrite bool `json:"overwrite"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RestoreOptions struct {
|
||||||
|
TargetPath string `json:"targetPath,omitempty"`
|
||||||
|
Overwrite bool `json:"overwrite"`
|
||||||
|
}
|
||||||
|
|
||||||
type TrashResult struct {
|
type TrashResult struct {
|
||||||
OriginalPath string `json:"originalPath"`
|
OriginalPath string `json:"originalPath"`
|
||||||
TrashPath string `json:"trashPath"`
|
TrashPath string `json:"trashPath"`
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue