| 1 | import { createStore } from "/js/AlpineStore.js"; |
| 2 | import { callJsonApi } from "/js/api.js"; |
| 3 | import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; |
| 4 | import { getCurrentUserDateString } from "/js/time-utils.js"; |
| 5 | |
| 6 | const SAVE_MESSAGE_MS = 1800; |
| 7 | const DESKTOP_DOCUMENT_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]); |
| 8 | |
| 9 | function currentContextId() { |
| 10 | try { |
| 11 | return globalThis.getContext?.() || ""; |
| 12 | } catch { |
| 13 | return ""; |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | function basename(path = "") { |
| 18 | const value = String(path || "").split("?")[0].split("#")[0]; |
| 19 | return value.split("/").filter(Boolean).pop() || "Untitled"; |
| 20 | } |
| 21 | |
| 22 | function extensionOf(path = "") { |
| 23 | const name = basename(path).toLowerCase(); |
| 24 | const index = name.lastIndexOf("."); |
| 25 | return index >= 0 ? name.slice(index + 1) : ""; |
| 26 | } |
| 27 | |
| 28 | function normalizeDocument(doc = {}) { |
| 29 | const path = doc.path || ""; |
| 30 | const extension = String(doc.extension || extensionOf(path)).toLowerCase(); |
| 31 | return { |
| 32 | ...doc, |
| 33 | extension, |
| 34 | title: doc.title || doc.basename || basename(path), |
| 35 | basename: doc.basename || basename(path), |
| 36 | path, |
| 37 | }; |
| 38 | } |
| 39 | |
| 40 | function documentLabel(document = {}) { |
| 41 | return document.title || document.basename || basename(document.path); |
| 42 | } |
| 43 | |
| 44 | async function callOffice(action, payload = {}) { |
| 45 | return await callJsonApi("/plugins/_office/office_session", { |
| 46 | action, |
| 47 | ctxid: currentContextId(), |
| 48 | ...payload, |
| 49 | }); |
| 50 | } |
| 51 | |
| 52 | const model = { |
| 53 | status: null, |
| 54 | loading: false, |
| 55 | error: "", |
| 56 | message: "", |
| 57 | _root: null, |
| 58 | _mode: "modal", |
| 59 | _initialized: false, |
| 60 | _saveMessageTimer: null, |
| 61 | _headerCleanup: null, |
| 62 | |
| 63 | async init() { |
| 64 | if (this._initialized) return; |
| 65 | this._initialized = true; |
| 66 | await this.refresh(); |
| 67 | }, |
| 68 | |
| 69 | async onMount(element = null, options = {}) { |
| 70 | await this.init(); |
| 71 | if (element) this._root = element; |
| 72 | this._mode = options?.mode === "canvas" ? "canvas" : "modal"; |
| 73 | if (this._mode === "modal") this.setupDocumentModal(element); |
| 74 | }, |
| 75 | |
| 76 | async onOpen(payload = {}) { |
| 77 | await this.init(); |
| 78 | await this.refresh(); |
| 79 | if (payload?.path || payload?.file_id) { |
| 80 | await this.openSession({ |
| 81 | path: payload.path || "", |
| 82 | file_id: payload.file_id || "", |
| 83 | refresh: payload.refresh === true, |
| 84 | source: payload.source || "", |
| 85 | }); |
| 86 | } |
| 87 | }, |
| 88 | |
| 89 | cleanup() { |
| 90 | this._headerCleanup?.(); |
| 91 | this._headerCleanup = null; |
| 92 | if (this._mode === "modal") this._root = null; |
| 93 | }, |
| 94 | |
| 95 | async refresh() { |
| 96 | try { |
| 97 | const status = await callOffice("status"); |
| 98 | this.status = status || {}; |
| 99 | this.error = ""; |
| 100 | } catch (error) { |
| 101 | this.error = error instanceof Error ? error.message : String(error); |
| 102 | } |
| 103 | }, |
| 104 | |
| 105 | async create(kind = "document", format = "") { |
| 106 | const fmt = String(format || (kind === "spreadsheet" ? "ods" : kind === "presentation" ? "odp" : "odt")).toLowerCase(); |
| 107 | const title = this.defaultTitle(kind, fmt); |
| 108 | await this.openSession({ |
| 109 | action: "create", |
| 110 | kind, |
| 111 | format: fmt, |
| 112 | title, |
| 113 | }); |
| 114 | }, |
| 115 | |
| 116 | async openFileBrowser() { |
| 117 | let workdirPath = "/a0/usr/workdir"; |
| 118 | try { |
| 119 | const response = await callJsonApi("settings_get", null); |
| 120 | workdirPath = response?.settings?.workdir_path || workdirPath; |
| 121 | } catch { |
| 122 | try { |
| 123 | const home = await callOffice("home"); |
| 124 | workdirPath = home?.path || workdirPath; |
| 125 | } catch { |
| 126 | // Keep the configured default path when the home lookup is unavailable. |
| 127 | } |
| 128 | } |
| 129 | await fileBrowserStore.open(workdirPath); |
| 130 | }, |
| 131 | |
| 132 | async openPath(path) { |
| 133 | await this.openSession({ path: String(path || "") }); |
| 134 | }, |
| 135 | |
| 136 | async openSession(payload = {}) { |
| 137 | this.loading = true; |
| 138 | this.error = ""; |
| 139 | try { |
| 140 | const response = await callOffice(payload.action || "open", payload); |
| 141 | if (response?.ok === false) { |
| 142 | this.error = response.error || "Document could not be opened."; |
| 143 | return null; |
| 144 | } |
| 145 | if (response?.requires_desktop || this.isDesktopDocument(response)) { |
| 146 | const document = normalizeDocument(response.document || response); |
| 147 | this.setMessage(`${documentLabel(document)} is ready. Use Open in Desktop to edit it.`); |
| 148 | await this.refresh(); |
| 149 | return response; |
| 150 | } |
| 151 | await this.refresh(); |
| 152 | return response; |
| 153 | } catch (error) { |
| 154 | this.error = error instanceof Error ? error.message : String(error); |
| 155 | return null; |
| 156 | } finally { |
| 157 | this.loading = false; |
| 158 | } |
| 159 | }, |
| 160 | |
| 161 | setMessage(value) { |
| 162 | this.message = value; |
| 163 | if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer); |
| 164 | this._saveMessageTimer = globalThis.setTimeout(() => { |
| 165 | this.message = ""; |
| 166 | this._saveMessageTimer = null; |
| 167 | }, SAVE_MESSAGE_MS); |
| 168 | }, |
| 169 | |
| 170 | isDesktopDocument(tab = {}) { |
| 171 | const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase(); |
| 172 | return DESKTOP_DOCUMENT_EXTENSIONS.has(ext); |
| 173 | }, |
| 174 | |
| 175 | defaultTitle(kind, fmt) { |
| 176 | const date = getCurrentUserDateString(); |
| 177 | if (fmt === "odt") return `Writer ${date}`; |
| 178 | if (fmt === "docx") return `DOCX ${date}`; |
| 179 | if (kind === "spreadsheet") return `Spreadsheet ${date}`; |
| 180 | if (kind === "presentation") return `Presentation ${date}`; |
| 181 | return `Document ${date}`; |
| 182 | }, |
| 183 | |
| 184 | async runNewMenuAction(action = "") { |
| 185 | const normalized = String(action || "").trim().toLowerCase(); |
| 186 | if (normalized === "open") return await this.openFileBrowser(); |
| 187 | if (normalized === "writer") return await this.create("document", "odt"); |
| 188 | if (normalized === "spreadsheet") return await this.create("spreadsheet", "ods"); |
| 189 | if (normalized === "presentation") return await this.create("presentation", "odp"); |
| 190 | return null; |
| 191 | }, |
| 192 | |
| 193 | installHeaderNewMenu(header = null) { |
| 194 | if (!header || header.querySelector(".office-header-actions")) return () => {}; |
| 195 | |
| 196 | const root = document.createElement("div"); |
| 197 | root.className = "office-header-actions"; |
| 198 | root.innerHTML = ` |
| 199 | <button type="button" class="office-header-new-button" aria-haspopup="menu" aria-expanded="false"> |
| 200 | <x-icon aria-hidden="true" name="add"></x-icon> |
| 201 | <span>New</span> |
| 202 | <x-icon class="office-new-chevron" aria-hidden="true" name="expand_more"></x-icon> |
| 203 | </button> |
| 204 | <div class="office-new-menu" role="menu" hidden> |
| 205 | <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="open"> |
| 206 | <x-icon aria-hidden="true" name="folder_open"></x-icon> |
| 207 | <span>Open</span> |
| 208 | </button> |
| 209 | <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="writer"> |
| 210 | <x-icon aria-hidden="true" name="description"></x-icon> |
| 211 | <span>Writer</span> |
| 212 | </button> |
| 213 | <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="spreadsheet"> |
| 214 | <x-icon aria-hidden="true" name="table_chart"></x-icon> |
| 215 | <span>Spreadsheet</span> |
| 216 | </button> |
| 217 | <button type="button" class="office-new-menu-item" role="menuitem" data-office-new-action="presentation"> |
| 218 | <x-icon aria-hidden="true" name="co_present"></x-icon> |
| 219 | <span>Presentation</span> |
| 220 | </button> |
| 221 | </div> |
| 222 | `; |
| 223 | |
| 224 | const button = root.querySelector(".office-header-new-button"); |
| 225 | const menu = root.querySelector(".office-new-menu"); |
| 226 | const setOpen = (open) => { |
| 227 | root.classList.toggle("is-open", open); |
| 228 | button?.setAttribute("aria-expanded", open.toString()); |
| 229 | if (menu) menu.hidden = !open; |
| 230 | }; |
| 231 | const onButtonClick = (event) => { |
| 232 | event.preventDefault(); |
| 233 | event.stopPropagation(); |
| 234 | setOpen(!root.classList.contains("is-open")); |
| 235 | }; |
| 236 | const onDocumentClick = (event) => { |
| 237 | if (!root.contains(event.target)) setOpen(false); |
| 238 | }; |
| 239 | const onDocumentKeydown = (event) => { |
| 240 | if (event.key === "Escape") setOpen(false); |
| 241 | }; |
| 242 | |
| 243 | button?.addEventListener("click", onButtonClick); |
| 244 | for (const item of root.querySelectorAll("[data-office-new-action]")) { |
| 245 | item.addEventListener("click", async (event) => { |
| 246 | event.preventDefault(); |
| 247 | event.stopPropagation(); |
| 248 | const action = event.currentTarget?.dataset?.officeNewAction || ""; |
| 249 | setOpen(false); |
| 250 | await this.runNewMenuAction(action); |
| 251 | }); |
| 252 | } |
| 253 | document.addEventListener("click", onDocumentClick); |
| 254 | document.addEventListener("keydown", onDocumentKeydown); |
| 255 | |
| 256 | const firstHeaderAction = header.querySelector(".modal-close"); |
| 257 | if (firstHeaderAction) { |
| 258 | firstHeaderAction.insertAdjacentElement("beforebegin", root); |
| 259 | } else { |
| 260 | header.appendChild(root); |
| 261 | } |
| 262 | |
| 263 | setOpen(false); |
| 264 | return () => { |
| 265 | button?.removeEventListener("click", onButtonClick); |
| 266 | document.removeEventListener("click", onDocumentClick); |
| 267 | document.removeEventListener("keydown", onDocumentKeydown); |
| 268 | root.remove(); |
| 269 | }; |
| 270 | }, |
| 271 | |
| 272 | setupDocumentModal(element = null) { |
| 273 | const root = element || document.querySelector(".office-panel"); |
| 274 | const inner = root?.closest?.(".modal-inner"); |
| 275 | const header = inner?.querySelector?.(".modal-header"); |
| 276 | if (!inner || !header || inner.dataset.officeModalReady === "1") return; |
| 277 | inner.dataset.officeModalReady = "1"; |
| 278 | inner.classList.add("office-modal"); |
| 279 | this._headerCleanup = () => { |
| 280 | delete inner.dataset.officeModalReady; |
| 281 | inner.classList.remove("office-modal"); |
| 282 | }; |
| 283 | const menuCleanup = this.installHeaderNewMenu(header); |
| 284 | const previousCleanup = this._headerCleanup; |
| 285 | this._headerCleanup = () => { |
| 286 | menuCleanup?.(); |
| 287 | previousCleanup?.(); |
| 288 | }; |
| 289 | }, |
| 290 | }; |
| 291 | |
| 292 | export const store = createStore("office", model); |