add file and folder download

This commit is contained in:
Nystik
2026-03-25 21:23:59 +01:00
parent b61d70f4a5
commit f55a015b64
6 changed files with 1173 additions and 28 deletions

View File

@@ -2,6 +2,12 @@
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)
### Changed

984
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ignis",
"version": "0.6.2",
"version": "0.6.4",
"private": true,
"description": "An Electron shim and server bridge for running Obsidian in a browser.",
"scripts": {
@@ -9,6 +9,7 @@
"dev": "npm run build && npm run dev:server"
},
"dependencies": {
"archiver": "^7.0.1",
"chokidar": "^3.6.0",
"compression": "^1.7.4",
"cors": "^2.8.5",

View File

@@ -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 {
async onload() {
@@ -10,20 +27,42 @@ class IgnisBridgePlugin extends Plugin {
this.registerEvent(
this.app.workspace.on("file-menu", (menu, file) => {
if (file instanceof TFolder) {
menu.addItem((item) => {
item
.setTitle("Upload file")
.setIcon("upload")
.onClick(() => {
this.showFilePicker(file);
});
});
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) => {
item
.setTitle("Upload file")
.setIcon("upload")
.onClick(() => this.showFilePicker(folder));
});
}
showFilePicker(targetFolder = null) {
const input = document.createElement("input");
input.type = "file";

View File

@@ -49,19 +49,17 @@ async function installPluginInVault(vaultPath) {
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");
await fs.promises.copyFile(
path.join(pluginSrcDir, "manifest.json"),
path.join(pluginDir, "manifest.json"),
);
await fs.promises.copyFile(
path.join(pluginSrcDir, "main.js"),
path.join(pluginDir, "main.js"),
);
}
const pluginSrcDir = path.join(__dirname, "..", "plugin");
await fs.promises.copyFile(
path.join(pluginSrcDir, "manifest.json"),
path.join(pluginDir, "manifest.json"),
);
await fs.promises.copyFile(
path.join(pluginSrcDir, "main.js"),
path.join(pluginDir, "main.js"),
);
const pluginsConfig = path.join(obsidianDir, "community-plugins.json");
let plugins = [];

View File

@@ -1,10 +1,59 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const archiver = require("archiver");
const config = require("../config");
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.
function getVaultRoot(req, res) {
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId;
@@ -210,7 +259,9 @@ router.post("/mkdir", async (req, res) => {
}
try {
await fs.promises.mkdir(resolved, { recursive: !!req.body.recursive });
await fs.promises.mkdir(resolved, {
recursive: !!req.body.recursive,
});
res.json({ ok: true });
} catch (e) {
@@ -397,7 +448,9 @@ router.get("/tree", async (req, res) => {
const tree = {};
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) {
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;