mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
add file and folder download
This commit is contained in:
@@ -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
984
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user