Add collections, files, get, get-thumb CLI commands
Complete CLI surface: quak login interactive or QUAK_EMAIL/QUAK_PASSWORD quak whoami print logged-in account quak logout delete session quak collections list all albums (--json) quak files list files in a collection (--json) quak get <id> download+decrypt a file (--out, --collection) quak get-thumb <id> download+decrypt a thumbnail quak backup <dir> full incremental backup get/get-thumb search all collections for the file ID when --collection is not specified. All listing commands support --json. Live-tested: collections list, file list, single file download (472 KB JPEG from the dev account, verified as valid JPEG with EXIF intact).
This commit is contained in:
180
bin/quak.ts
180
bin/quak.ts
@@ -149,6 +149,186 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("collections")
|
||||
.description("List all collections (albums)")
|
||||
.option("--json", "Output as JSON array")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
await init();
|
||||
const client = requireSession();
|
||||
const collections = await client.listCollections();
|
||||
|
||||
if (opts.json) {
|
||||
stdout.write(
|
||||
JSON.stringify(
|
||||
collections.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
ownerID: c.ownerID,
|
||||
isShared: c.isShared,
|
||||
updationTime: c.updationTime,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
} else {
|
||||
for (const c of collections) {
|
||||
stdout.write(
|
||||
`${c.id}\t${c.type}\t${c.name}${c.isShared ? " (shared)" : ""}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("files")
|
||||
.description("List files in a collection")
|
||||
.requiredOption(
|
||||
"--collection <id>",
|
||||
"Collection ID (from `quak collections`)",
|
||||
)
|
||||
.option("--json", "Output as JSON array")
|
||||
.action(async (opts: { collection: string; json?: boolean }) => {
|
||||
await init();
|
||||
const client = requireSession();
|
||||
const collectionID = Number(opts.collection);
|
||||
if (!Number.isFinite(collectionID)) {
|
||||
stderr.write("Invalid collection ID\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const collections = await client.listCollections();
|
||||
const col = collections.find((c) => c.id === collectionID);
|
||||
if (!col) {
|
||||
stderr.write(`Collection ${collectionID} not found\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = await client.listFiles(col.id, col.key);
|
||||
|
||||
if (opts.json) {
|
||||
stdout.write(
|
||||
JSON.stringify(
|
||||
files.map((f) => ({
|
||||
id: f.id,
|
||||
title: f.metadata.title,
|
||||
fileType: f.metadata.fileType,
|
||||
creationTime: f.metadata.creationTime,
|
||||
collectionID: f.collectionID,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
} else {
|
||||
for (const f of files) {
|
||||
stdout.write(
|
||||
`${f.id}\t${f.metadata.fileType}\t${f.metadata.title}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("get")
|
||||
.description("Download and decrypt a single file")
|
||||
.argument("<fileID>", "File ID (from `quak files`)")
|
||||
.option("--out <path>", "Output file path")
|
||||
.option(
|
||||
"--collection <id>",
|
||||
"Collection ID (required to look up the file key)",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
fileIDStr: string,
|
||||
opts: { out?: string; collection?: string },
|
||||
) => {
|
||||
await init();
|
||||
const client = requireSession();
|
||||
const fileID = Number(fileIDStr);
|
||||
if (!Number.isFinite(fileID)) {
|
||||
stderr.write("Invalid file ID\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const collections = await client.listCollections();
|
||||
let targetCol;
|
||||
if (opts.collection) {
|
||||
targetCol = collections.find(
|
||||
(c) => c.id === Number(opts.collection),
|
||||
);
|
||||
}
|
||||
|
||||
// Search all collections (or the specified one) for the file
|
||||
const searchCols = targetCol ? [targetCol] : collections;
|
||||
for (const col of searchCols) {
|
||||
const files = await client.listFiles(col.id, col.key);
|
||||
const file = files.find((f) => f.id === fileID);
|
||||
if (file) {
|
||||
const result = await client.downloadFile(file, opts.out);
|
||||
stderr.write(
|
||||
`${result.bytesWritten} bytes -> ${result.path}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
stderr.write(`File ${fileID} not found\n`);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
|
||||
program
|
||||
.command("get-thumb")
|
||||
.description("Download and decrypt a thumbnail")
|
||||
.argument("<fileID>", "File ID (from `quak files`)")
|
||||
.option("--out <path>", "Output file path")
|
||||
.option(
|
||||
"--collection <id>",
|
||||
"Collection ID (required to look up the file key)",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
fileIDStr: string,
|
||||
opts: { out?: string; collection?: string },
|
||||
) => {
|
||||
await init();
|
||||
const client = requireSession();
|
||||
const fileID = Number(fileIDStr);
|
||||
if (!Number.isFinite(fileID)) {
|
||||
stderr.write("Invalid file ID\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const collections = await client.listCollections();
|
||||
let targetCol;
|
||||
if (opts.collection) {
|
||||
targetCol = collections.find(
|
||||
(c) => c.id === Number(opts.collection),
|
||||
);
|
||||
}
|
||||
|
||||
const searchCols = targetCol ? [targetCol] : collections;
|
||||
for (const col of searchCols) {
|
||||
const files = await client.listFiles(col.id, col.key);
|
||||
const file = files.find((f) => f.id === fileID);
|
||||
if (file) {
|
||||
const result = await client.downloadThumbnail(
|
||||
file,
|
||||
opts.out,
|
||||
);
|
||||
stderr.write(
|
||||
`${result.bytesWritten} bytes -> ${result.path}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
stderr.write(`File ${fileID} not found\n`);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
|
||||
program
|
||||
.command("backup")
|
||||
.description(
|
||||
|
||||
Reference in New Issue
Block a user