add file and folder download
This commit is contained in:
parent
b61d70f4a5
commit
f55a015b64
6 changed files with 1173 additions and 28 deletions
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.6.4] - Slifer (2026-03-24)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Context menu items for downloading files and folders
|
||||||
|
|
||||||
## [0.6.3] - Slifer (2026-03-24)
|
## [0.6.3] - Slifer (2026-03-24)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
984
package-lock.json
generated
984
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ignis",
|
"name": "ignis",
|
||||||
"version": "0.6.2",
|
"version": "0.6.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "An Electron shim and server bridge for running Obsidian in a browser.",
|
"description": "An Electron shim and server bridge for running Obsidian in a browser.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"dev": "npm run build && npm run dev:server"
|
"dev": "npm run build && npm run dev:server"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,21 @@
|
||||||
const { Plugin, Notice, TFolder } = require("obsidian");
|
const { Plugin, Notice, TFile, TFolder } = require("obsidian");
|
||||||
|
|
||||||
|
function getVaultId() {
|
||||||
|
return window.__currentVaultId || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(endpoint, filePath, downloadName) {
|
||||||
|
const vaultId = getVaultId();
|
||||||
|
const url =
|
||||||
|
`/api/fs/${endpoint}` +
|
||||||
|
`?vault=${encodeURIComponent(vaultId)}` +
|
||||||
|
`&path=${encodeURIComponent(filePath)}`;
|
||||||
|
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = downloadName;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
class IgnisBridgePlugin extends Plugin {
|
class IgnisBridgePlugin extends Plugin {
|
||||||
async onload() {
|
async onload() {
|
||||||
|
|
@ -10,18 +27,40 @@ class IgnisBridgePlugin extends Plugin {
|
||||||
|
|
||||||
this.registerEvent(
|
this.registerEvent(
|
||||||
this.app.workspace.on("file-menu", (menu, file) => {
|
this.app.workspace.on("file-menu", (menu, file) => {
|
||||||
if (file instanceof TFolder) {
|
if (file instanceof TFile) {
|
||||||
|
this.addFileMenuItems(menu, file);
|
||||||
|
} else if (file instanceof TFolder) {
|
||||||
|
this.addFolderMenuItems(menu, file);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addFileMenuItems(menu, file) {
|
||||||
|
menu.addItem((item) => {
|
||||||
|
item
|
||||||
|
.setTitle("Download")
|
||||||
|
.setIcon("download")
|
||||||
|
.onClick(() => triggerDownload("download", file.path, file.name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addFolderMenuItems(menu, folder) {
|
||||||
|
menu.addItem((item) => {
|
||||||
|
item
|
||||||
|
.setTitle("Download as ZIP")
|
||||||
|
.setIcon("download")
|
||||||
|
.onClick(() =>
|
||||||
|
triggerDownload("download-zip", folder.path, `${folder.name}.zip`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
menu.addItem((item) => {
|
menu.addItem((item) => {
|
||||||
item
|
item
|
||||||
.setTitle("Upload file")
|
.setTitle("Upload file")
|
||||||
.setIcon("upload")
|
.setIcon("upload")
|
||||||
.onClick(() => {
|
.onClick(() => this.showFilePicker(folder));
|
||||||
this.showFilePicker(file);
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showFilePicker(targetFolder = null) {
|
showFilePicker(targetFolder = null) {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ async function installPluginInVault(vaultPath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await fs.promises.stat(pluginDir).catch(() => null))) {
|
|
||||||
await fs.promises.mkdir(pluginDir, { recursive: true });
|
await fs.promises.mkdir(pluginDir, { recursive: true });
|
||||||
|
|
||||||
const pluginSrcDir = path.join(__dirname, "..", "plugin");
|
const pluginSrcDir = path.join(__dirname, "..", "plugin");
|
||||||
|
|
@ -61,7 +60,6 @@ async function installPluginInVault(vaultPath) {
|
||||||
path.join(pluginSrcDir, "main.js"),
|
path.join(pluginSrcDir, "main.js"),
|
||||||
path.join(pluginDir, "main.js"),
|
path.join(pluginDir, "main.js"),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const pluginsConfig = path.join(obsidianDir, "community-plugins.json");
|
const pluginsConfig = path.join(obsidianDir, "community-plugins.json");
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,59 @@
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const archiver = require("archiver");
|
||||||
const config = require("../config");
|
const config = require("../config");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a filename for use in Content-Disposition header.
|
||||||
|
* Handles non-ASCII characters and special characters to prevent header injection.
|
||||||
|
* Uses RFC 5987 encoding for filename* parameter when needed.
|
||||||
|
*
|
||||||
|
* @param {string} filename - The filename to encode
|
||||||
|
* @returns {string} - Properly formatted Content-Disposition value
|
||||||
|
*/
|
||||||
|
function encodeContentDispositionFilename(filename) {
|
||||||
|
// Check if filename contains non-ASCII characters
|
||||||
|
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
|
||||||
|
|
||||||
|
// Escape quotes and backslashes in ASCII filename by prefixing with backslash
|
||||||
|
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
|
||||||
|
if (match === '"') return '\\"';
|
||||||
|
if (match === "\\") return "\\\\";
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove any control characters that could cause header injection
|
||||||
|
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
|
||||||
|
|
||||||
|
if (!hasNonASCII) {
|
||||||
|
// Simple ASCII filename - use standard format
|
||||||
|
return `attachment; filename="${sanitizedFilename}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-ASCII filename - use RFC 5987 encoding
|
||||||
|
// Encode using percent-encoding for UTF-8
|
||||||
|
const encodedFilename = encodeURIComponent(filename)
|
||||||
|
.replace(/['()]/g, function (c) {
|
||||||
|
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
|
||||||
|
})
|
||||||
|
.replace(/\*/g, "%2A");
|
||||||
|
|
||||||
|
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
|
||||||
|
// For fallback, replace non-ASCII with underscores
|
||||||
|
const asciiFallback = filename
|
||||||
|
.replace(/[^\x00-\x7F]/g, "_")
|
||||||
|
.replace(/["\\ ]/g, function (match) {
|
||||||
|
if (match === '"') return '\\"';
|
||||||
|
if (match === "\\") return "\\\\";
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the vault root for a request. Reads vault ID from query or body.
|
// Resolve the vault root for a request. Reads vault ID from query or body.
|
||||||
function getVaultRoot(req, res) {
|
function getVaultRoot(req, res) {
|
||||||
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId;
|
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId;
|
||||||
|
|
@ -210,7 +259,9 @@ router.post("/mkdir", async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(resolved, { recursive: !!req.body.recursive });
|
await fs.promises.mkdir(resolved, {
|
||||||
|
recursive: !!req.body.recursive,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -397,7 +448,9 @@ router.get("/tree", async (req, res) => {
|
||||||
const tree = {};
|
const tree = {};
|
||||||
|
|
||||||
async function walk(dir, prefix) {
|
async function walk(dir, prefix) {
|
||||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
const entries = await fs.promises.readdir(dir, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
||||||
|
|
@ -428,4 +481,72 @@ router.get("/tree", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/fs/download?path=...&vault=...
|
||||||
|
router.get("/download", async (req, res) => {
|
||||||
|
const resolved = guardPath(req, res);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(resolved);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Use /download-zip for directories" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = path.basename(resolved);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
encodeContentDispositionFilename(filename),
|
||||||
|
);
|
||||||
|
res.sendFile(resolved);
|
||||||
|
} catch (e) {
|
||||||
|
res
|
||||||
|
.status(e.code === "ENOENT" ? 404 : 500)
|
||||||
|
.json({ error: e.message, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/fs/download-zip?path=...&vault=...
|
||||||
|
router.get("/download-zip", async (req, res) => {
|
||||||
|
const resolved = guardPath(req, res);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(resolved);
|
||||||
|
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return res.status(400).json({ error: "Not a directory" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderName = path.basename(resolved);
|
||||||
|
res.setHeader("Content-Type", "application/zip");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
encodeContentDispositionFilename(folderName + ".zip"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||||
|
|
||||||
|
archive.on("error", (err) => {
|
||||||
|
res.status(500).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(res);
|
||||||
|
archive.directory(resolved, folderName);
|
||||||
|
archive.finalize();
|
||||||
|
} catch (e) {
|
||||||
|
res
|
||||||
|
.status(e.code === "ENOENT" ? 404 : 500)
|
||||||
|
.json({ error: e.message, code: e.code });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue