diff --git a/server/plugin/index.go b/server/plugin/index.go
index 09d59cf6..438f7780 100644
--- a/server/plugin/index.go
+++ b/server/plugin/index.go
@@ -2,6 +2,7 @@ package plugin
import (
. "github.com/mickael-kerjean/filestash/server/common"
+ _ "github.com/mickael-kerjean/filestash/server/plugin/plg_application_office"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_htpasswd"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_ldap"
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_local"
diff --git a/server/plugin/plg_application_office/Makefile b/server/plugin/plg_application_office/Makefile
new file mode 100644
index 00000000..6d4ed237
--- /dev/null
+++ b/server/plugin/plg_application_office/Makefile
@@ -0,0 +1,18 @@
+all:
+ make build
+ make install
+
+install:
+ zip -r application_office.zip . -x "lib/vendor/*"
+ mv application_office.zip ../../../dist/data/state/plugins/
+
+build:
+ make deps_zeta
+
+deps_zeta:
+ [ -d lib/lowa ] || mkdir lib/lowa
+ curl https://cdn.zetaoffice.net/zetaoffice_latest/soffice.js > lib/lowa/soffice.js
+ curl https://cdn.zetaoffice.net/zetaoffice_latest/soffice.wasm > lib/lowa/soffice.wasm.br
+ curl https://cdn.zetaoffice.net/zetaoffice_latest/soffice.data.js.metadata > lib/lowa/soffice.data.js.metadata
+ curl https://cdn.zetaoffice.net/zetaoffice_latest/soffice.data > lib/lowa/soffice.data.br
+ curl https://zetaoffice.net/demos/standalone/assets/vendor/zetajs/zeta.js > lib/lowa/zeta.js
diff --git a/server/plugin/plg_application_office/loader_lowa.css b/server/plugin/plg_application_office/loader_lowa.css
new file mode 100644
index 00000000..b57d4aee
--- /dev/null
+++ b/server/plugin/plg_application_office/loader_lowa.css
@@ -0,0 +1,61 @@
+@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/fontawesome.min.css");
+
+.component_word {
+ display: flex;
+ flex: 1 1 auto;
+}
+.component_word canvas {
+ border: none;
+ outline: none;
+ height: 100%;
+ width: 100%;
+}
+.component_menubar .action-item .texteditor svg.component_icon {
+ width: 16px;
+ margin: 1px 0 0 2px;
+}
+.component_menubar .action-item .texteditor.active svg.component_icon {
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 5px;
+}
+
+.component_menubar .action-item .texteditor input[type="number"] {
+ width: 15px;
+ color: var(--color);
+ border: 2px solid #f2f2f2;
+ background: #f2f2f2;
+ border-radius: 2px;
+ padding: 0 3px 0 3px;
+ margin: 0 3px;
+ -moz-appearance: textfield;
+}
+.component_menubar .action-item .texteditor input[type="number"]::-webkit-outer-spin-button,
+.component_menubar .action-item .texteditor input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+}
+
+.component_menubar .action-item .texteditor .fontawesome {
+ font-family: "FontAwesome";
+ color: var(--light);
+ background: inherit;
+ color: inherit;
+ border: none;
+}
+.component_menubar .action-item .texteditor select.fontawesome option {
+ background: var(--dark);
+}
+
+.component_menubar .action-item .texteditor.picker {
+ display: flex;
+ align-items: center;
+}
+.component_menubar .action-item .texteditor.picker input[type="color"] {
+ padding: 0;
+ width: 0;
+ border: none;
+}
+.component_word + .component_loader {
+ position: absolute;
+ inset: 0;
+ margin-top: 150px;
+}
diff --git a/server/plugin/plg_application_office/loader_lowa.go b/server/plugin/plg_application_office/loader_lowa.go
new file mode 100644
index 00000000..d7ffc92c
--- /dev/null
+++ b/server/plugin/plg_application_office/loader_lowa.go
@@ -0,0 +1,18 @@
+package plg_application_office
+
+import (
+ "net/http"
+
+ . "github.com/mickael-kerjean/filestash/server/common"
+)
+
+func init() {
+ Hooks.Register.Middleware(func(h HandlerFunc) HandlerFunc {
+ return func(app *App, res http.ResponseWriter, req *http.Request) {
+ head := res.Header()
+ head.Set("Cross-Origin-Opener-Policy", "same-origin")
+ head.Set("Cross-Origin-Embedder-Policy", "require-corp")
+ h(app, res, req)
+ }
+ })
+}
diff --git a/server/plugin/plg_application_office/loader_lowa.js b/server/plugin/plg_application_office/loader_lowa.js
new file mode 100644
index 00000000..c1b8a16d
--- /dev/null
+++ b/server/plugin/plg_application_office/loader_lowa.js
@@ -0,0 +1,286 @@
+import { createElement, onDestroy } from "../../lib/skeleton/index.js";
+import rxjs, { effect } from "../../lib/rx.js";
+import ajax from "../../lib/ajax.js";
+import { qs } from "../../lib/dom.js";
+import { join } from "../../lib/path.js";
+import { loadJS, loadCSS } from "../../helpers/loader.js";
+import { buttonDownload } from "../../pages/viewerpage/component_menubar.js";
+import { $ICON } from "../../pages/viewerpage/common_fab.js";
+import { save } from "../../pages/viewerpage/model_files.js";
+import "../../components/fab.js";
+
+import { $toolbar } from "./lib/dom.js";
+
+await loadCSS(import.meta.url, "./loader_lowa.css");
+
+let $canvas = null;
+window.Module = {
+ uno_scripts: [join(import.meta.url, "./lib/lowa/zeta.js"), join(import.meta.url, "./loader_lowa.uno.js")],
+ locateFile: (path, prefix) => (prefix || join(import.meta.url, "./lib/lowa/")) + path,
+};
+
+export default async function(render, { mime, getDownloadUrl, getFilename, $menubar, acl$ }) {
+ const canWrite = (await acl$.toPromise()).indexOf("POST") >= 0;
+ const $page = createElement(`
+
+
+
+
+ `);
+ render($page);
+
+ // feature1: init
+ const filename = getFilename();
+ const $fab = qs($page, `[is="component-fab"]`);
+ const $qcanvas = qs($page, "canvas");
+ if ($canvas) {
+ $qcanvas.remove();
+ $page.appendChild($canvas);
+ } else {
+ $canvas = $qcanvas;
+ }
+ Object.assign($canvas.style, {
+ width: "100%",
+ height: "100%",
+ });
+
+ // feature2: toolbar init
+ if (canWrite) {
+ $menubar.add(buttonDownload(filename, getDownloadUrl()));
+ if (isWriter(mime)) {
+ $menubar.add($toolbar.bullet);
+ $menubar.add($toolbar.alignment);
+ $menubar.add($toolbar.title);
+ }
+ $menubar.add($toolbar.size);
+ $menubar.add($toolbar.strike);
+ $menubar.add($toolbar.underline);
+ $menubar.add($toolbar.italic);
+ $menubar.add($toolbar.bold);
+ $menubar.add($toolbar.color);
+ }
+
+ // feature3: setup lowa
+ window.Module.canvas = $canvas;
+ await loadJS(import.meta.url, "./lib/lowa/soffice.js");
+ let port = await Module.uno_main;
+ onDestroy(() => {
+ $canvas.style.visibility = "hidden";
+ port.postMessage({ cmd: "destroy", mime });
+ });
+
+ // feature4: display rule for save button
+ const action$ = new rxjs.Subject();
+ if (canWrite) effect(rxjs.merge(rxjs.fromEvent($canvas, "keyup"), action$).pipe(rxjs.tap(() => {
+ $fab.classList.remove("hidden");
+ $fab.render($ICON.SAVING);
+ $fab.onclick = () => {
+ $fab.render($ICON.LOADING);
+ $fab.disabled = true;
+ port.postMessage({ cmd: "save" });
+ };
+ })));
+
+ // feature5: load file
+ await effect(ajax({ url: getDownloadUrl(), responseType: "arraybuffer" }).pipe(
+ rxjs.mergeMap(async ({ response }) => {
+ try { FS.mkdir("/tmp/office/"); } catch {}
+ await FS.writeFile("/tmp/office/" + filename , new Uint8Array(response));
+ await port.postMessage({ cmd: "load", filename, mime });
+ onDestroy(() => FS.unlink("/tmp/office/" + filename));
+ $canvas.focus();
+ }),
+ ));
+ await new Promise((resolve) => {
+ port.onmessage = function(e) {
+ switch (e.data.cmd) {
+ case "loaded":
+ window.dispatchEvent(new Event("resize"));
+ setTimeout(() => {
+ resolve();
+ $canvas.style.visibility = "visible";
+ }, 250);
+ break;
+ case "save":
+ const bytes = FS.readFile("/tmp/office/" + filename);
+ effect(save(new Blob([bytes], {})).pipe(rxjs.tap(() => {
+ $fab.classList.add("hidden");
+ $fab.render($ICON.SAVING);
+ $fab.disabled = false;
+ })));
+ break;
+ case "setFormat":
+ switch(e.data.id) {
+ case "Bold":
+ e.data.state ? $toolbar.bold.classList.add("active") : $toolbar.bold.classList.remove("active");
+ break;
+ case "Italic":
+ e.data.state ? $toolbar.italic.classList.add("active") : $toolbar.italic.classList.remove("active");
+ break;
+ case "Underline":
+ e.data.state ? $toolbar.underline.classList.add("active") : $toolbar.underline.classList.remove("active");
+ break;
+ case "Strikeout":
+ e.data.state ? $toolbar.strike.classList.add("active") : $toolbar.strike.classList.remove("active");
+ break;
+ case "LeftPara":
+ if (e.data.state) qs($toolbar.alignment, "select").value = "left";
+ break;
+ case "RightPara":
+ if (e.data.state) qs($toolbar.alignment, "select").value = "right";
+ break;
+ case "CenterPara":
+ if (e.data.state) qs($toolbar.alignment, "select").value = "center";
+ break;
+ case "JustifyPara":
+ if (e.data.state) qs($toolbar.alignment, "select").value = "justify";
+ break;
+ case "DefaultBullet":
+ qs($toolbar.bullet, "select").value = e.data.state ? "ul" : "normal";
+ break;
+ case "DefaultNumbering":
+ qs($toolbar.bullet, "select").value = e.data.state ? "ol" : "normal";
+ break;
+ case "StyleApply":
+ let value = "normal";
+ if (e.data.state === "Title") value = "title";
+ else if (e.data.state === "Heading 1") value = "head1";
+ else if (e.data.state === "Heading 2") value = "head2";
+ else if (e.data.state === "Heading 3") value = "head3";
+ qs($toolbar.title, "select").value = value;
+ break;
+ case "Color":
+ const hex = e.data.state && e.data.state > 0 ? "#" + e.data.state.toString(16).padStart(6, "0") : "#000000";
+ $toolbar.color.children[0].style.fill = hex;
+ $toolbar.color.children[1].value = hex;
+ break;
+ case "FontHeight":
+ const fontSize = e.data.state;
+ qs($toolbar.size, "input").value = fontSize;
+ break;
+ default:
+ console.log("format", e);
+ throw new Error("Unknown format");
+ }
+ $canvas.focus();
+ break;
+ default:
+ console.log("message", e);
+ throw new Error("Unknown message");
+ }
+ };
+ });
+
+ // feature6: toolbar events
+ $toolbar.bold.onclick = () => {
+ $toolbar.bold.classList.toggle("active");
+ action$.next();
+ port.postMessage({ cmd: "toggleFormatting", id: "Bold" });
+ };
+ $toolbar.italic.onclick = () => {
+ $toolbar.italic.classList.toggle("active");
+ action$.next();
+ port.postMessage({ cmd: "toggleFormatting", id: "Italic" });
+ };
+ $toolbar.underline.onclick = () => {
+ $toolbar.underline.classList.toggle("active");
+ action$.next();
+ port.postMessage({ cmd: "toggleFormatting", id: "Underline" });
+ };
+ $toolbar.bullet.onchange = (e) => {
+ switch(e.target.value) {
+ case "normal":
+ port.postMessage({ cmd: "toggleFormatting", id: "RemoveBullets" });
+ break;
+ case "ul":
+ port.postMessage({ cmd: "toggleFormatting", id: "DefaultBullet" });
+ break;
+ case "ol":
+ port.postMessage({ cmd: "toggleFormatting", id: "DefaultNumbering" });
+ break;
+ }
+ action$.next();
+ };
+ $toolbar.strike.onclick = () => {
+ $toolbar.strike.classList.toggle("active");
+ action$.next();
+ port.postMessage({ cmd: "toggleFormatting", id: "Strikeout" });
+ };
+ $toolbar.alignment.onchange = (e) => {
+ switch(e.target.value) {
+ case "left":
+ port.postMessage({ cmd: "toggleFormatting", id: "LeftPara" });
+ break;
+ case "right":
+ port.postMessage({ cmd: "toggleFormatting", id: "RightPara" });
+ break;
+ case "center":
+ port.postMessage({ cmd: "toggleFormatting", id: "CenterPara" });
+ break;
+ case "justify":
+ port.postMessage({ cmd: "toggleFormatting", id: "JustifyPara" });
+ break;
+ default:
+ throw new Error("Unknown tool alignment");
+ }
+ action$.next();
+ };
+ $toolbar.title.onchange = (e) => {
+ switch(e.target.value) {
+ case "normal":
+ port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Standard&FamilyName:string=ParagraphStyles" });
+ break;
+ case "title":
+ port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Title&FamilyName:string=ParagraphStyles" });
+ break;
+ case "head1":
+ port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Heading 1&FamilyName:string=ParagraphStyles" });
+ break;
+ case "head2":
+ port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Heading 2&FamilyName:string=ParagraphStyles" });
+ break;
+ case "head3":
+ port.postMessage({ cmd: "toggleFormatting", id: "StyleApply?Style:string=Heading 3&FamilyName:string=ParagraphStyles" });
+ break;
+ default:
+ throw new Error("Unknown text style");
+ }
+ action$.next();
+ };
+ $toolbar.color.onclick = (e) => {
+ if (e.target.tagName === "INPUT") return;
+ const $svg = e.target.closest("svg")
+ const $input = $svg.nextElementSibling;
+ $input.onchange = (e) => {
+ $svg.style.fill = e.target.value;
+ const color = parseInt(e.target.value.slice(1), 16);
+ port.postMessage({ cmd: "toggleFormatting", id: `Color?Color:long=${color}` })
+ };
+ $input.click();
+ action$.next();
+ };
+ effect(rxjs.fromEvent(qs($toolbar.size, "input"), "keyup").pipe(
+ rxjs.debounceTime(250),
+ rxjs.tap((e) => {
+ const fontSize = parseInt(e.target.value);
+ port.postMessage({ cmd: "toggleFormatting", id: `FontHeight?FontHeight.Height:float=${fontSize}` });
+ action$.next();
+ }),
+ ));
+
+ // feature7: workaround known lowa bug
+ // - when pressing escape, lowa goes out of fullscreen and show some unwanted stuff
+ // - context menu functions like "replace" image which does crash everything with errors generated from soffice.js
+ // - ctrl + s is broken
+ effect(rxjs.fromEvent($page, "keydown", { capture: true }).pipe(rxjs.tap((e) => {
+ if (e.key === "Escape") e.stopPropagation();
+ if (e.key === "s" && e.ctrlKey) e.stopPropagation();
+ })));
+ effect(rxjs.fromEvent($page, "mousedown", { capture: true }).pipe(rxjs.tap((e) => {
+ if (e.which === 3) e.stopPropagation();
+ })));
+}
+
+function isWriter(mime) {
+ return ["application/word", "application/msword", "application/rtf", "application/vnd.oasis.opendocument.text"].indexOf(mime) >= 0;
+}
diff --git a/server/plugin/plg_application_office/loader_lowa.uno.js b/server/plugin/plg_application_office/loader_lowa.uno.js
new file mode 100644
index 00000000..c9634c32
--- /dev/null
+++ b/server/plugin/plg_application_office/loader_lowa.uno.js
@@ -0,0 +1,123 @@
+// reference:
+// - uno programming: https://www.youtube.com/watch?v=CzxLKG9CUvo
+// - dispatch commands: https://wiki.documentfoundation.org/Development/DispatchCommands
+Module.zetajs.then(function(zetajs) {
+ init({
+ css: zetajs.uno.com.sun.star,
+ zetajs,
+ });
+});
+
+function init({ zetajs, css }) {
+ const context = zetajs.getUnoComponentContext();
+ const desktop = css.frame.Desktop.create(context);
+ let ctrl, xModel;
+
+ // UI Element: remove toolbar in writer
+ const config = css.configuration.ReadWriteAccess.create(context, "en-US");
+ ["Writer", "Calc", "Impress"].forEach((app) => {
+ const uielems = config.getByHierarchicalName(`/org.openoffice.Office.UI.${app}WindowState/UIElements/States`);
+ for (const i of uielems.getElementNames()) {
+ const uielem = uielems.getByName(i);
+ if (uielem.getByName("Visible")) uielem.setPropertyValue("Visible", false);
+ }
+ });
+
+ // Theme & Colors
+ const elmnts = config.getByHierarchicalName("/org.openoffice.Office.UI/ColorScheme/ColorSchemes");
+ for (const i of elmnts.getElementNames()) {
+ const colorScheme = elmnts.getByName(i);
+ // console.log(colorScheme.getElementNames());
+ colorScheme.getByName("AppBackground").setPropertyValue("Color", 16119285); // #f5f5f5
+ colorScheme.getByName("WriterPageBreaks").setPropertyValue("Color", 16119285); // #f5f5f5
+ colorScheme.getByName("WriterSectionBoundaries").setPropertyValue("Color", 16119285); // #f5f5f5
+ colorScheme.getByName("Shadow").setPropertyValue("Color", 16119285); // #f5f5f5
+ colorScheme.getByName("FontColor").setPropertyValue("Color", 2368548); // #242424
+ colorScheme.getByName("WriterHeaderFooterMark").setPropertyValue("Color", 16777215); // #ffffff
+ }
+ config.commitChanges();
+
+ zetajs.mainPort.onmessage = function(e) {
+ switch (e.data.cmd) {
+ case "destroy":
+ toggleTools({ mime: e.data.mime, css, ctrl, context });
+ xModel = null;
+ ctrl = null;
+ break;
+ case "load":
+ const { filename, mime } = e.data;
+ const in_path = `file:///tmp/office/${filename}`;
+ xModel = desktop.loadComponentFromURL(in_path, "_default", 0, []);
+ ctrl = xModel.getCurrentController();
+ ctrl.getFrame().LayoutManager.hideElement("private:resource/menubar/menubar");
+ ctrl.getFrame().LayoutManager.hideElement("private:resource/statusbar/statusbar");
+ ctrl.getFrame().getContainerWindow().FullScreen = true;
+ toggleTools({ mime, css, ctrl, context });
+ const commands = [ // ref: https://wiki.documentfoundation.org/Development/DispatchCommands
+ "Bold", "Italic", "Underline", "Strikeout", "LeftPara", "RightPara", "CenterPara",
+ "JustifyPara", "Color", "FontHeight", ...(isWriter(mime) ? ["StyleApply", "DefaultBullet", "DefaultNumbering"] : []),
+ ];
+ for (const id of commands) {
+ const urlObj = transformUrl(".uno:" + id, { css, context });
+ const listener = zetajs.unoObject([css.frame.XStatusListener], {
+ disposing: function(source) {},
+ statusChanged: function(state) {
+ state = zetajs.fromAny(state.State);
+ if (id === "StyleApply") state = state && state.StyleName || null;
+ else if (id === "Color") state = typeof state === "number" ? state : null;
+ else if (id === "FontHeight") state = state && state.Height || null;
+ else if (typeof state !== "boolean") state = false;
+
+ if (state === null) return;
+ zetajs.mainPort.postMessage({ cmd: "setFormat", id, state });
+ },
+ });
+ queryDispatch(urlObj, { ctrl }).addStatusListener(listener, urlObj);
+ }
+ zetajs.mainPort.postMessage({ cmd: "loaded" });
+ break;
+ case "save":
+ xModel.store();
+ zetajs.mainPort.postMessage({ cmd: "save" });
+ break;
+ case "toggleFormatting":
+ dispatch(".uno:" + e.data.id, { css, ctrl, context });
+ break;
+ default:
+ throw Error("Unknown message command: " + e.data.cmd);
+ }
+ }
+}
+
+function transformUrl(unoUrl, { css, context }) {
+ const ioparam = {
+ val: new css.util.URL({
+ Complete: unoUrl
+ }),
+ };
+ css.util.URLTransformer.create(context).parseStrict(ioparam);
+ return ioparam.val;
+}
+
+function queryDispatch(urlObj, { ctrl }) {
+ return ctrl.queryDispatch(urlObj, "_self", 0);
+}
+
+function dispatch(unoUrl, { css, ctrl, context }) {
+ const urlObj = transformUrl(unoUrl, { css, context });
+ queryDispatch(urlObj, { ctrl }).dispatch(urlObj, []);
+}
+
+function toggleTools({ css, ctrl, context, mime }) {
+ dispatch(".uno:Sidebar", { css, ctrl, context });
+ if (isCalc(mime)) dispatch(".uno:InputLineVisible", { css, ctrl, context });
+ if (isWriter(mime)) dispatch(".uno:Ruler", { css, ctrl, context });
+}
+
+function isWriter(mime) {
+ return ["application/word", "application/msword", "application/rtf", "application/vnd.oasis.opendocument.text"].indexOf(mime) >= 0;
+}
+
+function isCalc(mime) {
+ return ["application/excel", "application/vnd.ms-excel", "application/vnd.oasis.opendocument.spreadsheet"].indexOf(mime) >= 0;
+}
diff --git a/server/plugin/plg_application_office/manifest.json b/server/plugin/plg_application_office/manifest.json
new file mode 100644
index 00000000..f06fa5d3
--- /dev/null
+++ b/server/plugin/plg_application_office/manifest.json
@@ -0,0 +1,66 @@
+{
+ "author": "Filestash Pty Ltd",
+ "version": "v0.0",
+ "modules": [
+ {
+ "type": "xdg-open",
+ "mime": "application/word",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ },
+ {
+ "type": "xdg-open",
+ "mime": "application/msword",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ },
+ {
+ "type": "xdg-open",
+ "mime": "application/rtf",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ },
+ {
+ "type": "xdg-open",
+ "mime": "application/vnd.oasis.opendocument.text",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ },
+ {
+ "type": "xdg-open",
+ "mime": "application/excel",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ },
+ {
+ "type": "xdg-open",
+ "mime": "application/vnd.ms-excel",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ },
+ {
+ "type": "xdg-open",
+ "mime": "application/vnd.oasis.opendocument.spreadsheet",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ },
+ {
+ "type": "xdg-open",
+ "mime": "application/powerpoint",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ },
+ {
+ "type": "xdg-open",
+ "mime": "application/vnd.ms-powerpoint",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ },
+ {
+ "type": "xdg-open",
+ "mime": "application/vnd.oasis.opendocument.presentation",
+ "entrypoint": "loader_lowa.js",
+ "application": "skeleton"
+ }
+ ]
+}