main
js 292 lines 9.42 KB
Raw
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);