@axium/storage 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@ export declare function downloadItem(fileId: string): Promise<Blob>;
15
15
  export declare function updateItemMetadata(fileId: string, metadata: StorageItemUpdate): Promise<StorageItemMetadata>;
16
16
  export declare function deleteItem(fileId: string): Promise<StorageItemMetadata>;
17
17
  export declare function getUserStorage(userId: string): Promise<UserStorage>;
18
- export declare function getUserStorageInfo(userId: string): Promise<UserStorageInfo>;
18
+ export declare function getUserStats(userId: string): Promise<UserStorageInfo>;
19
19
  export declare function getUserTrash(userId: string): Promise<StorageItemMetadata[]>;
20
20
  export declare function itemsSharedWith(userId: string): Promise<StorageItemMetadata[]>;
21
21
  export declare function getUserStorageRoot(userId: string): Promise<StorageItemMetadata[]>;
@@ -80,12 +80,19 @@ export async function deleteItem(fileId) {
80
80
  }
81
81
  export async function getUserStorage(userId) {
82
82
  const result = await fetchAPI('GET', 'users/:id/storage', undefined, userId);
83
+ result.lastModified = new Date(result.lastModified);
84
+ if (result.lastTrashed)
85
+ result.lastTrashed = new Date(result.lastTrashed);
83
86
  for (const item of result.items)
84
87
  parseItem(item);
85
88
  return result;
86
89
  }
87
- export async function getUserStorageInfo(userId) {
88
- return await fetchAPI('OPTIONS', 'users/:id/storage', undefined, userId);
90
+ export async function getUserStats(userId) {
91
+ const result = await fetchAPI('OPTIONS', 'users/:id/storage', undefined, userId);
92
+ result.lastModified = new Date(result.lastModified);
93
+ if (result.lastTrashed)
94
+ result.lastTrashed = new Date(result.lastTrashed);
95
+ return result;
89
96
  }
90
97
  export async function getUserTrash(userId) {
91
98
  const result = await fetchAPI('GET', 'users/:id/storage/trash', undefined, userId);
@@ -6,23 +6,61 @@ import { statSync, unlinkSync } from 'node:fs';
6
6
  import { stat } from 'node:fs/promises';
7
7
  import { join, resolve } from 'node:path';
8
8
  import { styleText } from 'node:util';
9
- import { getUserStorage, getUserStorageInfo, getUserStorageRoot } from './api.js';
9
+ import { colorItem, formatItems } from '../node.js';
10
+ import * as api from './api.js';
10
11
  import { config, saveConfig } from './config.js';
11
- import { walkItems } from './paths.js';
12
+ import { cachePath, getDirectory, resolveItem, setQuiet, syncCache } from './local.js';
12
13
  import { computeDelta, doSync, fetchSyncItems } from './sync.js';
13
- const cli = program.command('files').helpGroup('Plugins:').description('CLI integration for @axium/storage');
14
+ const cli = program
15
+ .command('files')
16
+ .helpGroup('Plugins:')
17
+ .description('CLI integration for @axium/storage')
18
+ .option('-q, --quiet', 'Suppress output')
19
+ .hook('preAction', (action) => {
20
+ const opts = action.optsWithGlobals();
21
+ if (opts.quiet)
22
+ setQuiet(true);
23
+ });
14
24
  cli.command('usage')
15
25
  .description('Show your usage')
16
26
  .action(async () => {
17
- const { limits, usage } = await getUserStorageInfo(session().userId);
18
- console.log(`Items: ${usage.items} ${limits.user_items ? ' / ' + limits.user_items : ''}`);
19
- console.log(`Space: ${formatBytes(usage.bytes)} ${limits.user_size ? ' / ' + formatBytes(limits.user_size * 1_000_000) : ''}`);
27
+ const { limits, itemCount, usedBytes } = await api.getUserStats(session().userId);
28
+ console.log(`Items: ${itemCount} ${limits.user_items ? ' / ' + limits.user_items : ''}`);
29
+ console.log(`Space: ${formatBytes(usedBytes)} ${limits.user_size ? ' / ' + formatBytes(limits.user_size * 1_000_000) : ''}`);
20
30
  });
21
31
  cli.command('ls')
22
32
  .alias('list')
23
33
  .description('List the contents of a folder')
24
- .action(() => {
25
- io.error('Not implemented yet.');
34
+ .argument('<path>', 'remote folder path')
35
+ .option('-l, --long', 'Show more details')
36
+ .option('-h, --human-readable', 'Show sizes in human readable format')
37
+ .action(async function (path) {
38
+ const { users } = await syncCache().catch(io.handleError);
39
+ const { long, humanReadable } = this.optsWithGlobals();
40
+ const items = await getDirectory(path).catch(io.handleError);
41
+ if (!long) {
42
+ console.log(items.map(colorItem).join('\t'));
43
+ return;
44
+ }
45
+ console.log('total ' + items.length);
46
+ for (const text of formatItems({ items, users, humanReadable }))
47
+ console.log(text);
48
+ });
49
+ cli.command('mkdir')
50
+ .description('Create a remote folder')
51
+ .argument('<path>', 'remote folder path to create')
52
+ .action(async (path) => {
53
+ const pathParts = path.split('/');
54
+ const name = pathParts.pop();
55
+ const parentPath = pathParts.join('/');
56
+ const parent = !parentPath ? null : await resolveItem(parentPath).catch(io.handleError);
57
+ if (parent) {
58
+ if (!parent)
59
+ io.exit('Could not resolve parent folder.');
60
+ if (parent.type != 'inode/directory')
61
+ io.exit('Parent path is not a directory.');
62
+ }
63
+ await api.uploadItem(new Blob([], { type: 'inode/directory' }), { parentId: parent?.id, name }).catch(io.handleError);
26
64
  });
27
65
  cli.command('status')
28
66
  .option('-v, --verbose', 'Show more details')
@@ -31,26 +69,26 @@ cli.command('status')
31
69
  console.log(styleText('bold', `${config.sync.length} synced folder(s):`));
32
70
  for (const sync of config.sync) {
33
71
  if (opt.refresh)
34
- await fetchSyncItems(sync.itemId, sync.name);
35
- const delta = computeDelta(sync.itemId, sync.localPath);
72
+ await fetchSyncItems(sync.item, sync.name);
73
+ const delta = computeDelta(sync);
36
74
  if (opt.verbose) {
37
- console.log(styleText('underline', sync.localPath + ':'));
38
- if (delta.synced.length == delta.items.length && !delta.localOnly.length) {
75
+ console.log(styleText('underline', sync.local_path + ':'));
76
+ if (delta.synced.length == delta.items.length && !delta.local_only.length) {
39
77
  console.log('\t' + styleText('blueBright', 'All files are synced!'));
40
78
  continue;
41
79
  }
42
- for (const { _path } of delta.localOnly)
80
+ for (const { _path } of delta.local_only)
43
81
  console.log('\t' + styleText('green', '+ ' + _path));
44
- for (const { path } of delta.remoteOnly)
82
+ for (const { path } of delta.remote_only)
45
83
  console.log('\t' + styleText('red', '- ' + path));
46
84
  for (const { path, modifiedAt } of delta.modified) {
47
- const outdated = modifiedAt.getTime() > statSync(join(sync.localPath, path)).mtime.getTime();
85
+ const outdated = modifiedAt.getTime() > statSync(join(sync.local_path, path)).mtime.getTime();
48
86
  console.log('\t' + styleText('yellow', '~ ' + path) + (outdated ? ' (outdated)' : ''));
49
87
  }
50
88
  }
51
89
  else {
52
- console.log(sync.localPath + ':', Object.entries(delta)
53
- .map(([name, items]) => `${styleText('blueBright', items.length.toString())} ${name}`)
90
+ console.log(sync.local_path + ':', Object.entries(delta)
91
+ .map(([name, items]) => `${styleText('blueBright', items.length.toString())} ${name.replaceAll('_', '-')}`)
54
92
  .join(', '));
55
93
  }
56
94
  }
@@ -62,30 +100,27 @@ cli.command('add')
62
100
  .action(async (localPath, remoteName) => {
63
101
  localPath = resolve(localPath);
64
102
  for (const sync of config.sync) {
65
- if (sync.localPath == localPath || localPath.startsWith(sync.localPath + '/'))
103
+ if (sync.local_path == localPath || localPath.startsWith(sync.local_path + '/'))
66
104
  io.exit('This local path is already being synced.');
67
- if (sync.remotePath == remoteName || remoteName.startsWith(sync.remotePath + '/'))
105
+ if (sync.remote_path == remoteName || remoteName.startsWith(sync.remote_path + '/'))
68
106
  io.exit('This remote path is already being synced.');
69
107
  }
70
- const { userId } = session();
71
108
  const local = await stat(localPath).catch(e => io.exit(e.toString()));
72
109
  if (!local.isDirectory())
73
110
  io.exit('Local path is not a directory.');
74
- /**
75
- * @todo Add an endpoint to fetch directories (maybe with the full paths?)
76
- */
77
- const allItems = remoteName.includes('/') ? (await getUserStorage(userId)).items : await getUserStorageRoot(userId);
78
- const remote = walkItems(remoteName, allItems);
111
+ const remote = await resolveItem(remoteName);
79
112
  if (!remote)
80
113
  io.exit('Could not resolve remote path.');
81
114
  if (remote.type != 'inode/directory')
82
115
  io.exit('Remote path is not a directory.');
83
116
  config.sync.push({
84
117
  name: remote.name,
85
- itemId: remote.id,
86
- localPath,
87
- lastSynced: new Date(),
88
- remotePath: remoteName,
118
+ item: remote.id,
119
+ local_path: localPath,
120
+ last_synced: new Date(),
121
+ remote_path: remoteName,
122
+ include_dotfiles: false,
123
+ exclude: [],
89
124
  });
90
125
  await fetchSyncItems(remote.id);
91
126
  saveConfig();
@@ -97,19 +132,20 @@ cli.command('unsync')
97
132
  .argument('<path>', 'local path to the folder to stop syncing')
98
133
  .action((localPath) => {
99
134
  localPath = resolve(localPath);
100
- const index = config.sync.findIndex(sync => sync.localPath == localPath);
135
+ const index = config.sync.findIndex(sync => sync.local_path == localPath);
101
136
  if (index == -1)
102
137
  io.exit('This local path is not being synced.');
103
- unlinkSync(join(configDir, 'sync', config.sync[index].itemId + '.json'));
138
+ unlinkSync(join(configDir, 'sync', config.sync[index].item + '.json'));
104
139
  config.sync.splice(index, 1);
105
140
  saveConfig();
106
141
  });
107
142
  cli.command('sync')
108
143
  .description('Sync files')
109
- .addOption(new Option('--delete', 'Delete local/remote files that were deleted remotely/locally')
144
+ .addOption(new Option('--delete <mode>', 'Delete local/remote files that were deleted remotely/locally')
110
145
  .choices(['local', 'remote', 'none'])
111
146
  .default('none'))
112
147
  .option('-d, --dry-run', 'Show what would be done, but do not make any changes')
148
+ .option('-v, --verbose', 'Show more details')
113
149
  .argument('[sync]', 'The name of the Sync to sync')
114
150
  .action(async (name, opt) => {
115
151
  if (name) {
@@ -122,3 +158,37 @@ cli.command('sync')
122
158
  for (const sync of config.sync)
123
159
  await doSync(sync, opt);
124
160
  });
161
+ const cliCache = cli.command('cache').description('Manage the local cache');
162
+ cliCache
163
+ .command('clear')
164
+ .description('Clear the local cache')
165
+ .action(() => unlinkSync(cachePath));
166
+ cliCache
167
+ .command('refresh')
168
+ .description('Force a refresh of the local cache from the server')
169
+ .action(async () => {
170
+ await syncCache(true).catch(io.handleError);
171
+ });
172
+ cliCache
173
+ .command('dump')
174
+ .description('Dump the local cache')
175
+ .option('-v, --verbose', 'Show more details')
176
+ .addOption(new Option('-j, --json', 'Output as JSON').conflicts(['verbose', 'quiet']))
177
+ .action(async function () {
178
+ const opt = this.optsWithGlobals();
179
+ const data = await syncCache(false).catch(io.handleError);
180
+ if (opt.json) {
181
+ console.log(JSON.stringify(data));
182
+ return;
183
+ }
184
+ console.log(`Cache contains ${data.items.length} items and ${Object.keys(data.users).length} users.`);
185
+ if (opt.quiet || !opt.verbose)
186
+ return;
187
+ console.log(styleText('bold', 'Items:'));
188
+ for (const text of formatItems({ ...data, humanReadable: true }))
189
+ console.log(text);
190
+ console.log(styleText('bold', 'Users:'));
191
+ for (const user of Object.values(data.users)) {
192
+ console.log(user.name, `<${user.email}>`, styleText('dim', `(${user.id})`));
193
+ }
194
+ });
@@ -2,11 +2,13 @@ import * as z from 'zod';
2
2
  declare const ClientStorageConfig: z.ZodObject<{
3
3
  sync: z.ZodArray<z.ZodObject<{
4
4
  name: z.ZodString;
5
- itemId: z.ZodUUID;
6
- localPath: z.ZodString;
7
- lastSynced: z.ZodCoercedDate<unknown>;
8
- remotePath: z.ZodString;
9
- }, z.core.$strip>>;
5
+ item: z.ZodUUID;
6
+ local_path: z.ZodString;
7
+ last_synced: z.ZodCoercedDate<unknown>;
8
+ remote_path: z.ZodString;
9
+ include_dotfiles: z.ZodDefault<z.ZodBoolean>;
10
+ exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
11
+ }, z.core.$loose>>;
10
12
  }, z.core.$loose>;
11
13
  export interface ClientStorageConfig extends z.infer<typeof ClientStorageConfig> {
12
14
  }
@@ -1,6 +1,5 @@
1
1
  import { configDir } from '@axium/client/cli/config';
2
- import { debug, writeJSON } from '@axium/core/node/io';
3
- import { readFileSync } from 'node:fs';
2
+ import { debug, readJSON, writeJSON } from '@axium/core/node/io';
4
3
  import { join } from 'node:path/posix';
5
4
  import * as z from 'zod';
6
5
  import { Sync } from './sync.js';
@@ -10,10 +9,10 @@ const ClientStorageConfig = z.looseObject({
10
9
  const configPath = join(configDir, 'storage.json');
11
10
  export let config = { sync: [] };
12
11
  try {
13
- config = ClientStorageConfig.parse(JSON.parse(readFileSync(configPath, 'utf-8')));
12
+ config = readJSON(configPath, ClientStorageConfig);
14
13
  }
15
14
  catch (e) {
16
- debug('Could not load @axium/storage config: ' + (e instanceof z.core.$ZodError ? z.prettifyError(e) : e.message));
15
+ debug('Could not load @axium/storage config: ' + e);
17
16
  }
18
17
  export function saveConfig() {
19
18
  writeJSON(configPath, config);
@@ -2,5 +2,5 @@ import { config } from './config.js';
2
2
  import { doSync } from './sync.js';
3
3
  export async function run() {
4
4
  for (const sync of config.sync)
5
- await doSync(sync, { delete: 'none', dryRun: false });
5
+ await doSync(sync, { delete: 'none', dryRun: false, verbose: true });
6
6
  }
@@ -0,0 +1,58 @@
1
+ import { UserPublic } from '@axium/core';
2
+ import * as z from 'zod';
3
+ import { StorageItemMetadata } from '../common.js';
4
+ export declare function setQuiet(enabled: boolean): void;
5
+ export declare function walkItems(path: string, items: StorageItemMetadata[]): StorageItemMetadata | null;
6
+ declare const StorageCache: z.ZodObject<{
7
+ items: z.ZodArray<z.ZodObject<{
8
+ createdAt: z.ZodCoercedDate<unknown>;
9
+ dataURL: z.ZodString;
10
+ hash: z.ZodOptional<z.ZodNullable<z.ZodString>>;
11
+ id: z.ZodUUID;
12
+ immutable: z.ZodBoolean;
13
+ modifiedAt: z.ZodCoercedDate<unknown>;
14
+ name: z.ZodString;
15
+ userId: z.ZodUUID;
16
+ parentId: z.ZodNullable<z.ZodUUID>;
17
+ publicPermission: z.ZodEnum<{
18
+ readonly None: 0;
19
+ readonly Read: 1;
20
+ readonly Comment: 2;
21
+ readonly Edit: 3;
22
+ readonly Manage: 5;
23
+ }> & {
24
+ readonly None: 0;
25
+ readonly Read: 1;
26
+ readonly Comment: 2;
27
+ readonly Edit: 3;
28
+ readonly Manage: 5;
29
+ };
30
+ size: z.ZodInt;
31
+ trashedAt: z.ZodNullable<z.ZodCoercedDate<unknown>>;
32
+ type: z.ZodString;
33
+ metadata: z.ZodRecord<z.ZodString, z.ZodUnknown>;
34
+ }, z.core.$strip>>;
35
+ users: z.ZodRecord<z.ZodUUID, z.ZodObject<{
36
+ id: z.ZodUUID;
37
+ name: z.ZodString;
38
+ email: z.ZodOptional<z.ZodEmail>;
39
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodDate>>>;
40
+ image: z.ZodOptional<z.ZodNullable<z.ZodURL>>;
41
+ preferences: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
42
+ roles: z.ZodArray<z.ZodString>;
43
+ registeredAt: z.ZodCoercedDate<unknown>;
44
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
45
+ }, z.core.$strip>>;
46
+ }, z.core.$strip>;
47
+ interface StorageCache extends z.infer<typeof StorageCache> {
48
+ users: Record<string, UserPublic>;
49
+ }
50
+ export declare const cachePath: string;
51
+ export declare let cachedData: StorageCache | null;
52
+ /**
53
+ * @param force true => always refresh, false => never refresh, null (default) => refresh if outdated
54
+ */
55
+ export declare function syncCache(force?: boolean | null): Promise<StorageCache>;
56
+ export declare function resolveItem(path: string): Promise<StorageItemMetadata | null>;
57
+ export declare function getDirectory(path: string): Promise<StorageItemMetadata[]>;
58
+ export {};
@@ -0,0 +1,90 @@
1
+ import { cacheDir, session } from '@axium/client/cli/config';
2
+ import { userInfo } from '@axium/client/user';
3
+ import { UserPublic } from '@axium/core';
4
+ import * as io from '@axium/core/node/io';
5
+ import { ENOENT, ENOTDIR } from 'node:constants';
6
+ import { stat } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import * as z from 'zod';
9
+ import { StorageItemMetadata } from '../common.js';
10
+ import { getUserStats, getUserStorage } from './api.js';
11
+ let quiet = false;
12
+ export function setQuiet(enabled) {
13
+ quiet = enabled;
14
+ }
15
+ export function walkItems(path, items) {
16
+ const resolved = [];
17
+ let currentItem = null, currentParentId = null;
18
+ const target = path.split('/').filter(p => p);
19
+ for (const part of target) {
20
+ for (const item of items) {
21
+ if (item.parentId != currentParentId || item.name != part)
22
+ continue;
23
+ currentItem = item;
24
+ currentParentId = item.id;
25
+ resolved.push(part);
26
+ break;
27
+ }
28
+ }
29
+ return currentItem;
30
+ }
31
+ const StorageCache = z.object({
32
+ items: StorageItemMetadata.array(),
33
+ users: z.record(z.uuid(), UserPublic),
34
+ });
35
+ export const cachePath = join(cacheDir, 'storage.json');
36
+ export let cachedData = null;
37
+ /**
38
+ * @param force true => always refresh, false => never refresh, null (default) => refresh if outdated
39
+ */
40
+ export async function syncCache(force = null) {
41
+ if (cachedData)
42
+ return cachedData;
43
+ const cacheUpdated = await stat(cachePath)
44
+ .then(stats => stats.mtimeMs)
45
+ .catch(() => 0);
46
+ const { userId } = session();
47
+ let { lastModified, lastTrashed } = await getUserStats(userId);
48
+ lastTrashed ??= new Date(0);
49
+ if ((force || cacheUpdated < lastModified.getTime() || cacheUpdated < lastTrashed.getTime()) && force !== false) {
50
+ if (!quiet)
51
+ io.start('Updating and loading item metadata');
52
+ try {
53
+ const { items } = await getUserStorage(userId);
54
+ const users = {};
55
+ for (const item of items) {
56
+ users[item.userId] ||= await userInfo(item.userId);
57
+ }
58
+ cachedData = { items, users };
59
+ io.writeJSON(cachePath, cachedData);
60
+ if (!quiet)
61
+ io.done();
62
+ }
63
+ catch (e) {
64
+ const message = e instanceof Error ? e.message : String(e);
65
+ if (quiet)
66
+ io.exit('Failed to update item metadata: ' + message);
67
+ else
68
+ io.exit(message);
69
+ }
70
+ }
71
+ else {
72
+ cachedData = io.readJSON(cachePath, StorageCache);
73
+ }
74
+ return cachedData;
75
+ }
76
+ export async function resolveItem(path) {
77
+ const { items } = await syncCache();
78
+ return walkItems(path, items);
79
+ }
80
+ export async function getDirectory(path) {
81
+ const { items } = await syncCache();
82
+ if (path == '/' || path == '')
83
+ return items.filter(item => item.parentId === null);
84
+ const dir = walkItems(path, items);
85
+ if (!dir)
86
+ throw ENOENT;
87
+ if (dir.type != 'inode/directory')
88
+ throw ENOTDIR;
89
+ return items.filter(item => item.parentId === dir.id);
90
+ }
@@ -11,11 +11,13 @@ import '../polyfills.js';
11
11
  */
12
12
  export declare const Sync: z.ZodObject<{
13
13
  name: z.ZodString;
14
- itemId: z.ZodUUID;
15
- localPath: z.ZodString;
16
- lastSynced: z.ZodCoercedDate<unknown>;
17
- remotePath: z.ZodString;
18
- }, z.core.$strip>;
14
+ item: z.ZodUUID;
15
+ local_path: z.ZodString;
16
+ last_synced: z.ZodCoercedDate<unknown>;
17
+ remote_path: z.ZodString;
18
+ include_dotfiles: z.ZodDefault<z.ZodBoolean>;
19
+ exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
20
+ }, z.core.$loose>;
19
21
  export interface Sync extends z.infer<typeof Sync> {
20
22
  }
21
23
  /** Local metadata about a storage item to sync */
@@ -37,19 +39,29 @@ export interface ItemMetadata {
37
39
  export interface Delta {
38
40
  synced: LocalItem[];
39
41
  modified: LocalItem[];
40
- remoteOnly: LocalItem[];
42
+ remote_only: LocalItem[];
41
43
  items: LocalItem[];
42
44
  _items: Map<string, LocalItem>;
43
- localOnly: (fs.Dirent<string> & {
45
+ local_only: (fs.Dirent<string> & {
44
46
  _path: string;
45
47
  })[];
46
48
  }
47
49
  /**
48
50
  * Computes the changes between the local and remote, in the direction of the remote.
49
51
  */
50
- export declare function computeDelta(id: string, localPath: string): Delta;
52
+ export declare function computeDelta(sync: Sync): Delta;
53
+ /**
54
+ * A helper to run an action on an array, with support for dry-runs and displaying a progress counter or listing each item.
55
+ */
56
+ export declare function applyAction<T>(opt: {
57
+ dryRun: boolean;
58
+ verbose: boolean;
59
+ }, items: T[], label: (item: T) => string, action: (item: T) => Promise<void | string> | void | string): Promise<void>;
51
60
  export interface SyncOptions {
52
61
  delete: 'local' | 'remote' | 'none';
53
62
  dryRun: boolean;
63
+ verbose: boolean;
64
+ }
65
+ export interface SyncStats {
54
66
  }
55
- export declare function doSync(sync: Sync, opt: SyncOptions): Promise<void>;
67
+ export declare function doSync(sync: Sync, opt: SyncOptions): Promise<SyncStats>;