1import {
2 call,
3 createDocumentResource,
4 createListResource,
5 createResource,
6 getCachedDocumentResource,
7 toast,
8} from "frappe-ui";
9import { computed, effectScope, ref, shallowRef, watch } from "vue";
10import {
11 KNOWLEDGE_BASE_PREVIEW_KEY,
12 type KnowledgeBasePreview,
13} from "../knowledgeBasePreview";
14import { __ } from "../translation";
15import { CUSTOMER_PORTAL_ROOT, getErrorMessage, isSafeLink } from "../utils";
16
17type Link = KnowledgeBasePreview["links"][number];
18
19type Draft = KnowledgeBasePreview;
20
21const SETTINGS_FIELDS = ["banner_image", "banner_preset"] as const;
22
23// Quick links live in a form script; its last line holds them as JSON.
24const LINKS_SCRIPT = "Knowledge Base Quick Links";
25const JSON_MARKER = "//JSON: ";
26
27// Only the last line: labels may contain the marker; hand edits may not parse.
28function parseLinks(script: string): Link[] {
29 const lastLine = script.trimEnd().split("\n").pop() || "";
30 if (!lastLine.startsWith(JSON_MARKER)) return [];
31 try {
32 const links = JSON.parse(lastLine.slice(JSON_MARKER.length));
33 return Array.isArray(links) ? links : [];
34 } catch {
35 return [];
36 }
37}
38
39// Lazy singleton shared by desk and portal settings.
40export const knowledgeBaseDraft = shallowRef<ReturnType<
41 typeof createDraft
42> | null>(null);
43
44export function useKnowledgeBaseDraft() {
45 // Detached: the singleton outlives the first form, so must its watchers.
46 return (knowledgeBaseDraft.value ??= effectScope(true).run(createDraft)!);
47}
48
49function createDraft() {
50 // Reuse: re-creating a cached doc refetches it.
51 const settings =
52 getCachedDocumentResource("OD Settings", "OD Settings") ||
53 createDocumentResource({ doctype: "OD Settings", name: "OD Settings" });
54 const categories = createListResource({
55 doctype: "OD Article Category",
56 fields: ["name", "category_name", "icon", "pinned", "pinned_order"],
57 orderBy: "category_name asc",
58 pageLength: 99999,
59 auto: true,
60 });
61 const accessRows = createResource({
62 url: "frappe.client.get_list",
63 params: {
64 doctype: "OD Article",
65 fields: ["category", "visibility"],
66 group_by: "category, visibility",
67 order_by: "category asc",
68 limit_page_length: 0,
69 },
70 auto: true,
71 });
72 const linksScript = createResource({
73 url: "frappe.client.get_value",
74 params: {
75 doctype: "OD Form Script",
76 filters: { name: LINKS_SCRIPT },
77 fieldname: "script",
78 },
79 auto: true,
80 });
81
82 const saved = computed<Draft | null>(() => {
83 if (!settings.doc || !categories.data || !linksScript.data) return null;
84 const script: string = linksScript.data.script || "";
85 return {
86 banner_image: settings.doc.banner_image || "",
87 banner_preset: settings.doc.banner_preset || "",
88 pinned: categories.data
89 .filter((row) => row.pinned)
90 .sort((a, b) => a.pinned_order - b.pinned_order)
91 .map((row) => row.name),
92 links: parseLinks(script),
93 };
94 });
95
96 // Track `saved` but keep unsaved edits: a pin save must not wipe them.
97 const draft = ref<Draft | null>(null);
98 watch(
99 () => JSON.stringify(saved.value),
100 (json, previous) => {
101 const next = JSON.parse(json);
102 const before = previous ? JSON.parse(previous) : null;
103 if (next && before && draft.value) {
104 const now = normalize(draft.value);
105 const was = normalize(before);
106 for (const key of Object.keys(next) as (keyof Draft)[]) {
107 if (JSON.stringify(now[key]) !== JSON.stringify(was[key])) {
108 next[key] = draft.value[key];
109 }
110 }
111 }
112 draft.value = next;
113 },
114 { immediate: true }
115 );
116
117 const categoryOptions = computed(() => {
118 const access: Record<string, string> = {};
119 for (const row of accessRows.data || []) {
120 access[row.category] = access[row.category] ? "Mixed" : row.visibility;
121 }
122 // Customers never see Private categories; already pinned ones stay to unpin.
123 return (categories.data || [])
124 .filter((row) => access[row.name] !== "Private" || row.pinned)
125 .map((row) => ({
126 label: row.category_name,
127 value: row.name,
128 icon: row.icon?.startsWith("lucide-") ? row.icon : "lucide-folder",
129 access: access[row.name],
130 }));
131 });
132
133 const changed = computed(() => {
134 if (!draft.value || !saved.value) return [];
135 const now = normalize(draft.value);
136 const before = normalize(saved.value);
137 return Object.keys(now).filter(
138 (key) => JSON.stringify(now[key]) !== JSON.stringify(before[key])
139 );
140 });
141 const isDirty = computed(() => changed.value.length > 0);
142
143 const saving = ref(false);
144
145 async function save() {
146 const value = normalize(draft.value!);
147 if (value.links.some((link) => !link.label || !link.url)) {
148 return toast.error(__("Each link needs a label and a URL"));
149 }
150 const unsafe = value.links.find((link) => !isSafeLink(link.url));
151 if (unsafe) {
152 return toast.error(
153 __("{0}: use a web address, an email link or a path starting with /", [
154 unsafe.label,
155 ])
156 );
157 }
158 saving.value = true;
159 try {
160 const fields = SETTINGS_FIELDS.filter((key) =>
161 changed.value.includes(key)
162 );
163 if (fields.length) {
164 await call("frappe.client.set_value", {
165 doctype: "OD Settings",
166 name: "OD Settings",
167 fieldname: Object.fromEntries(fields.map((key) => [key, value[key]])),
168 });
169 }
170 if (changed.value.includes("links")) await saveLinks(value.links);
171 if (changed.value.includes("pinned")) await savePinned(value.pinned);
172 clearPreview();
173 await Promise.all([
174 settings.reload(),
175 linksScript.reload(),
176 categories.reload(),
177 ]);
178 toast.success(__("Settings updated"));
179 return true;
180 } catch (error) {
181 getErrorMessage(error, true);
182 } finally {
183 saving.value = false;
184 }
185 }
186
187 // Order is 1-based; 0 means unpinned.
188 async function savePinned(pinned: string[]) {
189 const { failed_docs } = await call("frappe.client.bulk_update", {
190 docs: categories.data
191 .map((row) => ({ row, order: pinned.indexOf(row.name) + 1 }))
192 .filter(
193 ({ row, order }) =>
194 Boolean(row.pinned) !== order > 0 || row.pinned_order !== order
195 )
196 .map(({ row, order }) => ({
197 doctype: "OD Article Category",
198 docname: row.name,
199 pinned: order > 0 ? 1 : 0,
200 pinned_order: order,
201 })),
202 });
203 if (failed_docs.length) {
204 throw new Error(__("Could not update the pinned categories"));
205 }
206 }
207
208 function saveLinks(links: Link[]) {
209 const json = JSON.stringify(links);
210 const script = `function setupForm() {\n return { links: ${json} };\n}\n${JSON_MARKER}${json}`;
211 if (linksScript.data?.script) {
212 return call("frappe.client.set_value", {
213 doctype: "OD Form Script",
214 name: LINKS_SCRIPT,
215 fieldname: "script",
216 value: script,
217 });
218 }
219 return call("frappe.client.insert", {
220 doc: {
221 doctype: "OD Form Script",
222 name: LINKS_SCRIPT,
223 dt: "OD Ticket",
224 apply_to_knowledge_base: 1,
225 enabled: 1,
226 script,
227 },
228 });
229 }
230
231 async function savePins(pinned: string[]) {
232 try {
233 await savePinned(pinned);
234 // The merge watch keeps a draft that differs from the old saved order.
235 draft.value!.pinned = [...pinned];
236 await categories.reload();
237 toast.success(__("Pinned categories updated"));
238 return true;
239 } catch (error) {
240 getErrorMessage(error, true);
241 return false;
242 }
243 }
244
245 // Saves at once, bypassing the draft.
246 function setToggle(fieldname: string, value: boolean) {
247 settings.setValue.submit(
248 { [fieldname]: value },
249 { onSuccess: () => toast.success(__("Settings updated")) }
250 );
251 }
252
253 function discard() {
254 draft.value = JSON.parse(JSON.stringify(saved.value));
255 clearPreview();
256 }
257
258 // Open preview tabs read this until it's removed.
259 function clearPreview() {
260 try {
261 localStorage.removeItem(KNOWLEDGE_BASE_PREVIEW_KEY);
262 } catch {}
263 }
264
265 function preview() {
266 try {
267 localStorage.setItem(
268 KNOWLEDGE_BASE_PREVIEW_KEY,
269 JSON.stringify(normalize(draft.value!))
270 );
271 } catch {
272 return toast.error(__("Could not open the preview"));
273 }
274 window.open(`${CUSTOMER_PORTAL_ROOT}?preview=1`, "_blank");
275 }
276
277 return {
278 draft,
279 settings,
280 setToggle,
281 savePins,
282 categoryOptions,
283 isDirty,
284 saving,
285 save,
286 discard,
287 preview,
288 };
289}
290
291// Ignore whitespace and empty link rows when diffing.
292function normalize(draft: Draft): Draft {
293 return {
294 ...draft,
295 links: draft.links
296 .map(({ label, url, open_in_new_tab }) => ({
297 label: label?.trim() || "",
298 url: url?.trim() || "",
299 open_in_new_tab: Boolean(open_in_new_tab),
300 }))
301 .filter((link) => link.label || link.url),
302 };
303}
304