@wizzlethorpe/vaults 0.9.4 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/foundry-importer.bundle.js +1270 -0
- package/dist/foundry-importer.js +14 -44
- package/dist/foundry-importer.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
// ../foundry/scripts/api.mjs
|
|
2
|
+
function url(vault, path) {
|
|
3
|
+
if (!vault?.url) throw new Error("Vault URL is not configured.");
|
|
4
|
+
const u = new URL(path, vault.url.endsWith("/") ? vault.url : vault.url + "/");
|
|
5
|
+
if (vault.token) u.searchParams.set("_token", vault.token);
|
|
6
|
+
return u.toString();
|
|
7
|
+
}
|
|
8
|
+
async function fetchJson(u) {
|
|
9
|
+
const res = await fetch(u);
|
|
10
|
+
if (!res.ok) throw new Error(`GET ${u} \u2192 ${res.status}`);
|
|
11
|
+
return res.json();
|
|
12
|
+
}
|
|
13
|
+
async function fetchManifest(vault) {
|
|
14
|
+
return fetchJson(url(vault, "/_manifest.json"));
|
|
15
|
+
}
|
|
16
|
+
var BATCH_SIZE = 100;
|
|
17
|
+
var BATCH_CONCURRENCY = 4;
|
|
18
|
+
var DIRECT_CONCURRENCY = 8;
|
|
19
|
+
async function fetchSourceBatch(vault, paths) {
|
|
20
|
+
if (paths.length === 0) return /* @__PURE__ */ new Map();
|
|
21
|
+
if (vault.public) return fetchSourceDirect(vault, paths);
|
|
22
|
+
const endpoint = url(vault, "/_batch");
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for (let i = 0; i < paths.length; i += BATCH_SIZE) chunks.push(paths.slice(i, i + BATCH_SIZE));
|
|
25
|
+
const out = /* @__PURE__ */ new Map();
|
|
26
|
+
let next = 0;
|
|
27
|
+
const workers = Array.from({ length: Math.min(BATCH_CONCURRENCY, chunks.length) }, async () => {
|
|
28
|
+
while (next < chunks.length) {
|
|
29
|
+
const idx = next++;
|
|
30
|
+
const res = await fetch(endpoint, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "text/plain" },
|
|
33
|
+
body: chunks[idx].join("\n")
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) throw new Error(`POST /_batch \u2192 ${res.status}`);
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
if (data.files) {
|
|
38
|
+
for (const [p, content] of Object.entries(data.files)) out.set(p, content);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
await Promise.all(workers);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
async function fetchSourceDirect(vault, paths) {
|
|
46
|
+
const out = /* @__PURE__ */ new Map();
|
|
47
|
+
let next = 0;
|
|
48
|
+
const workers = Array.from({ length: Math.min(DIRECT_CONCURRENCY, paths.length) }, async () => {
|
|
49
|
+
while (next < paths.length) {
|
|
50
|
+
const idx = next++;
|
|
51
|
+
const path = paths[idx];
|
|
52
|
+
const u = url(vault, "/" + path);
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(u);
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
if (res.status !== 404) console.warn(`Vaults | GET ${path} \u2192 ${res.status}`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
out.set(path, await res.text());
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.warn(`Vaults | GET ${path} failed:`, err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
await Promise.all(workers);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ../foundry/scripts/settings.mjs
|
|
70
|
+
var MODULE_ID = "vaults";
|
|
71
|
+
|
|
72
|
+
// ../foundry/scripts/util.mjs
|
|
73
|
+
function escapeAttr(s) {
|
|
74
|
+
return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
75
|
+
}
|
|
76
|
+
function escapeHtml(s) {
|
|
77
|
+
return String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
78
|
+
}
|
|
79
|
+
function escapeBraces(s) {
|
|
80
|
+
return String(s).replace(/[{}]/g, "");
|
|
81
|
+
}
|
|
82
|
+
async function hexDigest(algorithm, text) {
|
|
83
|
+
const buf = await crypto.subtle.digest(algorithm, new TextEncoder().encode(text));
|
|
84
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ../foundry/scripts/ids.mjs
|
|
88
|
+
async function det(kind, key) {
|
|
89
|
+
const hex = await hexDigest("SHA-1", `vaults:${kind}:${key}`);
|
|
90
|
+
return hex.slice(0, 16);
|
|
91
|
+
}
|
|
92
|
+
function folderOfPath(path) {
|
|
93
|
+
const i = path.lastIndexOf("/");
|
|
94
|
+
return i < 0 ? "" : path.slice(0, i);
|
|
95
|
+
}
|
|
96
|
+
var entryId = (vaultId, path) => det("entry", `${vaultId}:${folderOfPath(path)}`);
|
|
97
|
+
var pageId = (vaultId, path) => det("page", `${vaultId}:${path}`);
|
|
98
|
+
var folderId = (vaultId, path) => det("folder", `${vaultId}:${path}`);
|
|
99
|
+
var instanceId = (vaultId, path) => det("instance", `${vaultId}:${path}`);
|
|
100
|
+
var subdocId = (vaultId, path, pointer) => det("subdoc", `${vaultId}:${path}:${pointer}`);
|
|
101
|
+
|
|
102
|
+
// ../foundry/scripts/parser.mjs
|
|
103
|
+
var CACHED_EXT_RE = /\.(png|jpe?g|webp|gif|svg|avif|tiff?|bmp|heic|apng|ogg|mp3|m4a|wav|flac|opus|aac|mp4|webm|mov|ogv|pdf|epub|json)$/i;
|
|
104
|
+
|
|
105
|
+
// ../foundry/scripts/media.mjs
|
|
106
|
+
var CACHE_DIR = "vaults-cache";
|
|
107
|
+
var BATCH_SIZE2 = 25;
|
|
108
|
+
var BATCH_BYTE_BUDGET = 8 * 1024 * 1024;
|
|
109
|
+
var BATCH_CONCURRENCY2 = 4;
|
|
110
|
+
function isCacheable(path) {
|
|
111
|
+
if (!CACHED_EXT_RE.test(path)) return false;
|
|
112
|
+
if (path.startsWith("_") || path.includes("/_")) return false;
|
|
113
|
+
if (/\.preview\.json$/i.test(path)) return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
function localFileUrl(vaultId, vaultPath) {
|
|
117
|
+
const worldId = game.world?.id;
|
|
118
|
+
if (!worldId) throw new Error("No active world; cache path unavailable.");
|
|
119
|
+
const segs = vaultPath.split("/").map(encodeURIComponent).join("/");
|
|
120
|
+
const relative = `worlds/${worldId}/${CACHE_DIR}/${vaultId}/${segs}`;
|
|
121
|
+
return foundry.utils?.getRoute?.(relative) ?? `/${relative}`;
|
|
122
|
+
}
|
|
123
|
+
function vaultCacheDir(vaultId) {
|
|
124
|
+
const worldId = game.world.id;
|
|
125
|
+
return `worlds/${worldId}/${CACHE_DIR}/${vaultId}`;
|
|
126
|
+
}
|
|
127
|
+
async function syncImages(host, vault, manifestFiles) {
|
|
128
|
+
const remoteImages = /* @__PURE__ */ new Map();
|
|
129
|
+
for (const f of manifestFiles) {
|
|
130
|
+
if (isCacheable(f.path)) remoteImages.set(f.path, f.hash);
|
|
131
|
+
}
|
|
132
|
+
const lastImageManifest = host.getVaultState(vault.id).lastImageManifest;
|
|
133
|
+
const last = new Map(Object.entries(lastImageManifest || {}));
|
|
134
|
+
const toDownload = [];
|
|
135
|
+
for (const [path, hash] of remoteImages) {
|
|
136
|
+
if (last.get(path) !== hash) toDownload.push(path);
|
|
137
|
+
}
|
|
138
|
+
const toDelete = [...last.keys()].filter((p) => !remoteImages.has(p));
|
|
139
|
+
if (toDownload.length === 0 && toDelete.length === 0) return { downloaded: 0, removed: 0, errors: 0 };
|
|
140
|
+
const baseDir = vaultCacheDir(vault.id);
|
|
141
|
+
const worldRoot = `worlds/${game.world.id}`;
|
|
142
|
+
const dirsNeeded = /* @__PURE__ */ new Set();
|
|
143
|
+
const addChain = (fullPath) => {
|
|
144
|
+
const sub = fullPath.startsWith(worldRoot + "/") ? fullPath.slice(worldRoot.length + 1) : "";
|
|
145
|
+
if (!sub) return;
|
|
146
|
+
let acc = worldRoot;
|
|
147
|
+
for (const seg of sub.split("/")) {
|
|
148
|
+
acc += "/" + seg;
|
|
149
|
+
dirsNeeded.add(acc);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
addChain(baseDir);
|
|
153
|
+
for (const p of toDownload) {
|
|
154
|
+
const segs = p.split("/").slice(0, -1);
|
|
155
|
+
if (segs.length > 0) addChain(`${baseDir}/${segs.join("/")}`);
|
|
156
|
+
}
|
|
157
|
+
await ensureDirs([...dirsNeeded]);
|
|
158
|
+
const sizeOf = /* @__PURE__ */ new Map();
|
|
159
|
+
for (const f of manifestFiles) sizeOf.set(f.path, f.size ?? 0);
|
|
160
|
+
const chunks = [];
|
|
161
|
+
let chunk = [];
|
|
162
|
+
let chunkBytes = 0;
|
|
163
|
+
for (const path of toDownload) {
|
|
164
|
+
const bytes = sizeOf.get(path) ?? 0;
|
|
165
|
+
if (chunk.length > 0 && (chunk.length >= BATCH_SIZE2 || chunkBytes + bytes > BATCH_BYTE_BUDGET)) {
|
|
166
|
+
chunks.push(chunk);
|
|
167
|
+
chunk = [];
|
|
168
|
+
chunkBytes = 0;
|
|
169
|
+
}
|
|
170
|
+
chunk.push(path);
|
|
171
|
+
chunkBytes += bytes;
|
|
172
|
+
}
|
|
173
|
+
if (chunk.length > 0) chunks.push(chunk);
|
|
174
|
+
let next = 0;
|
|
175
|
+
const downloaded = [];
|
|
176
|
+
const errors = [];
|
|
177
|
+
const workers = Array.from({ length: Math.min(BATCH_CONCURRENCY2, chunks.length) }, async () => {
|
|
178
|
+
while (next < chunks.length) {
|
|
179
|
+
const idx = next++;
|
|
180
|
+
const chunk2 = chunks[idx];
|
|
181
|
+
try {
|
|
182
|
+
const blobs = await fetchImagesBatch(vault, chunk2);
|
|
183
|
+
for (const path of chunk2) {
|
|
184
|
+
const blob = blobs.get(path);
|
|
185
|
+
if (!blob) {
|
|
186
|
+
errors.push({ path, err: new Error("missing in batch response") });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
await uploadToWorld(baseDir, path, blob);
|
|
191
|
+
downloaded.push(path);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
errors.push({ path, err });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
for (const path of chunk2) errors.push({ path, err });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
await Promise.all(workers);
|
|
202
|
+
if (errors.length > 0) {
|
|
203
|
+
console.warn(`Vaults | ${errors.length} image(s) failed to download:`, errors);
|
|
204
|
+
}
|
|
205
|
+
let removed = 0;
|
|
206
|
+
for (const path of toDelete) {
|
|
207
|
+
try {
|
|
208
|
+
await deleteFromWorld(baseDir, path);
|
|
209
|
+
removed++;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.warn(`Vaults | could not remove orphan ${path}:`, err?.message || err);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const persisted = {};
|
|
215
|
+
for (const [path, hash] of remoteImages) {
|
|
216
|
+
if (last.get(path) === hash) {
|
|
217
|
+
persisted[path] = hash;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (downloaded.includes(path)) persisted[path] = hash;
|
|
221
|
+
}
|
|
222
|
+
await host.setVaultState(vault.id, { lastImageManifest: persisted });
|
|
223
|
+
return { downloaded: downloaded.length, removed, errors: errors.length };
|
|
224
|
+
}
|
|
225
|
+
async function deleteVaultCache(vaultId) {
|
|
226
|
+
const baseDir = vaultCacheDir(vaultId);
|
|
227
|
+
const impl = fp();
|
|
228
|
+
if (typeof impl.deleteFile !== "function") return false;
|
|
229
|
+
try {
|
|
230
|
+
await impl.deleteFile("data", baseDir);
|
|
231
|
+
return true;
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.warn(`Vaults | could not remove cache dir ${baseDir}:`, err?.message || err);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async function fetchImagesBatch(vault, paths) {
|
|
238
|
+
if (vault.public) return fetchImagesDirect(vault, paths);
|
|
239
|
+
const res = await fetch(url(vault, "/_batch-images"), {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "Content-Type": "text/plain" },
|
|
242
|
+
// CORS-simple, no preflight
|
|
243
|
+
body: paths.join("\n")
|
|
244
|
+
});
|
|
245
|
+
if (!res.ok) throw new Error(`POST /_batch-images \u2192 ${res.status}`);
|
|
246
|
+
const data = await res.json();
|
|
247
|
+
const out = /* @__PURE__ */ new Map();
|
|
248
|
+
for (const [path, b64] of Object.entries(data.files || {})) {
|
|
249
|
+
out.set(path, base64ToBlob(b64, guessMime(path)));
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
async function fetchImagesDirect(vault, paths) {
|
|
254
|
+
const out = /* @__PURE__ */ new Map();
|
|
255
|
+
const PARALLEL = 6;
|
|
256
|
+
let next = 0;
|
|
257
|
+
const workers = Array.from({ length: Math.min(PARALLEL, paths.length) }, async () => {
|
|
258
|
+
while (next < paths.length) {
|
|
259
|
+
const idx = next++;
|
|
260
|
+
const path = paths[idx];
|
|
261
|
+
try {
|
|
262
|
+
const res = await fetch(url(vault, "/" + path));
|
|
263
|
+
if (!res.ok) continue;
|
|
264
|
+
const blob = await res.blob();
|
|
265
|
+
const typed = blob.type ? blob : new Blob([await blob.arrayBuffer()], { type: guessMime(path) });
|
|
266
|
+
out.set(path, typed);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.warn(`Vaults | GET ${path} failed:`, err);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
await Promise.all(workers);
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
function base64ToBlob(b64, type) {
|
|
276
|
+
const binary = atob(b64);
|
|
277
|
+
const bytes = new Uint8Array(binary.length);
|
|
278
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
279
|
+
return new Blob([bytes], { type });
|
|
280
|
+
}
|
|
281
|
+
function fp() {
|
|
282
|
+
return foundry.applications?.apps?.FilePicker?.implementation ?? FilePicker.implementation ?? FilePicker;
|
|
283
|
+
}
|
|
284
|
+
async function uploadToWorld(baseDir, path, blob) {
|
|
285
|
+
const segs = path.split("/");
|
|
286
|
+
const filename = segs.pop();
|
|
287
|
+
const dir = segs.length > 0 ? `${baseDir}/${segs.join("/")}` : baseDir;
|
|
288
|
+
const file = new File([blob], filename, { type: blob.type || guessMime(filename) });
|
|
289
|
+
const result = await fp().upload("data", dir, file, {}, { notify: false });
|
|
290
|
+
if (result === false || result?.status === "error") {
|
|
291
|
+
throw new Error(`upload failed: ${result?.message || "unknown"} (path=${dir}/${filename})`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function ensureDirs(paths) {
|
|
295
|
+
paths.sort((a, b) => a.length - b.length);
|
|
296
|
+
for (const p of paths) {
|
|
297
|
+
try {
|
|
298
|
+
await fp().createDirectory("data", p, {});
|
|
299
|
+
} catch (err) {
|
|
300
|
+
const msg = String(err?.message || err);
|
|
301
|
+
if (!/exists|already/i.test(msg)) throw err;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function deleteFromWorld(baseDir, path) {
|
|
306
|
+
const full = `${baseDir}/${path}`;
|
|
307
|
+
const impl = fp();
|
|
308
|
+
if (typeof impl.deleteFile === "function") {
|
|
309
|
+
await impl.deleteFile("data", full);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
throw new Error("FilePicker.deleteFile is not available in this Foundry version.");
|
|
313
|
+
}
|
|
314
|
+
function guessMime(filename) {
|
|
315
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
316
|
+
return {
|
|
317
|
+
webp: "image/webp",
|
|
318
|
+
png: "image/png",
|
|
319
|
+
jpg: "image/jpeg",
|
|
320
|
+
jpeg: "image/jpeg",
|
|
321
|
+
gif: "image/gif",
|
|
322
|
+
svg: "image/svg+xml",
|
|
323
|
+
avif: "image/avif",
|
|
324
|
+
tiff: "image/tiff",
|
|
325
|
+
bmp: "image/bmp",
|
|
326
|
+
heic: "image/heic",
|
|
327
|
+
apng: "image/apng",
|
|
328
|
+
ogg: "audio/ogg",
|
|
329
|
+
mp3: "audio/mpeg",
|
|
330
|
+
m4a: "audio/mp4",
|
|
331
|
+
wav: "audio/wav",
|
|
332
|
+
flac: "audio/flac",
|
|
333
|
+
opus: "audio/ogg",
|
|
334
|
+
aac: "audio/aac",
|
|
335
|
+
mp4: "video/mp4",
|
|
336
|
+
webm: "video/webm",
|
|
337
|
+
mov: "video/quicktime",
|
|
338
|
+
ogv: "video/ogg",
|
|
339
|
+
pdf: "application/pdf",
|
|
340
|
+
epub: "application/epub+zip",
|
|
341
|
+
json: "application/json"
|
|
342
|
+
}[ext] || "application/octet-stream";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ../foundry/scripts/links.mjs
|
|
346
|
+
var ANCHOR_RE = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
|
|
347
|
+
var MEDIA_SRC_RE = /<(img|audio|video)\b([^>]*?)src="([^"]+)"([^>]*)>/gi;
|
|
348
|
+
var ATTR_HREF_RE = /\bhref="([^"]+)"/i;
|
|
349
|
+
var ATTR_CLASS_RE = /\bclass="([^"]+)"/i;
|
|
350
|
+
var TAG_RE = /<[^>]+>/g;
|
|
351
|
+
function buildPathIndex(manifestFiles) {
|
|
352
|
+
const paths = /* @__PURE__ */ new Set();
|
|
353
|
+
const idOverrides = /* @__PURE__ */ new Map();
|
|
354
|
+
for (const f of manifestFiles) {
|
|
355
|
+
if (!f.path.endsWith(".body.html")) continue;
|
|
356
|
+
const mdPath = f.path.replace(/\.body\.html$/i, "") + ".md";
|
|
357
|
+
paths.add(mdPath);
|
|
358
|
+
const override = f.meta?.foundry?.id;
|
|
359
|
+
if (typeof override === "string" && override) idOverrides.set(mdPath, override);
|
|
360
|
+
}
|
|
361
|
+
return { paths, idOverrides };
|
|
362
|
+
}
|
|
363
|
+
function logicalPathFromHref(href) {
|
|
364
|
+
const decoded = decodeHtmlEntities(href);
|
|
365
|
+
const cleaned = decoded.replace(/^\//, "").split("#")[0];
|
|
366
|
+
try {
|
|
367
|
+
return decodeURIComponent(cleaned) + ".md";
|
|
368
|
+
} catch {
|
|
369
|
+
return cleaned + ".md";
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function decodeHtmlEntities(s) {
|
|
373
|
+
const ta = document.createElement("textarea");
|
|
374
|
+
ta.innerHTML = s;
|
|
375
|
+
return ta.value;
|
|
376
|
+
}
|
|
377
|
+
async function transformHtmlForFoundry(vault, html, index) {
|
|
378
|
+
html = await rewriteWikilinks(vault.id, html, index);
|
|
379
|
+
html = rewriteMediaSrcs(vault.id, html);
|
|
380
|
+
html = rewritePassthroughLinks(vault.id, html);
|
|
381
|
+
html = await applyDomTransforms(html, vault, index);
|
|
382
|
+
return html;
|
|
383
|
+
}
|
|
384
|
+
async function applyDomTransforms(html, vault, index) {
|
|
385
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
386
|
+
let touched = false;
|
|
387
|
+
touched = stripWebOnlyWidgets(doc) || touched;
|
|
388
|
+
touched = flattenBasesTabs(doc) || touched;
|
|
389
|
+
touched = neutralizeEnrichersInCode(doc) || touched;
|
|
390
|
+
touched = await rewriteBasesCardLinks(doc, vault.id, index) || touched;
|
|
391
|
+
touched = rewriteDiceButtons(doc) || touched;
|
|
392
|
+
touched = wrapRestrictedCalloutsAsSecret(doc, vault) || touched;
|
|
393
|
+
return touched ? doc.body.innerHTML : html;
|
|
394
|
+
}
|
|
395
|
+
var WEB_ONLY_WIDGET_SELECTOR = ".vaults-battlemap";
|
|
396
|
+
function stripWebOnlyWidgets(doc) {
|
|
397
|
+
const widgets = doc.querySelectorAll(WEB_ONLY_WIDGET_SELECTOR);
|
|
398
|
+
for (const el of widgets) el.remove();
|
|
399
|
+
return widgets.length > 0;
|
|
400
|
+
}
|
|
401
|
+
function rewriteDiceButtons(doc) {
|
|
402
|
+
const buttons = doc.querySelectorAll("button.dice-roll[data-formula]");
|
|
403
|
+
if (buttons.length === 0) return false;
|
|
404
|
+
for (const btn of buttons) {
|
|
405
|
+
const formula = btn.getAttribute("data-formula");
|
|
406
|
+
if (!formula) continue;
|
|
407
|
+
btn.replaceWith(doc.createTextNode(`[[/r ${formula}]]`));
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
function flattenBasesTabs(doc) {
|
|
412
|
+
const tabbeds = doc.querySelectorAll(".bases-tabbed");
|
|
413
|
+
if (tabbeds.length === 0) return false;
|
|
414
|
+
for (const tabbed of tabbeds) {
|
|
415
|
+
const fragment = doc.createDocumentFragment();
|
|
416
|
+
for (const panel of tabbed.querySelectorAll(".bases-tab-panel")) {
|
|
417
|
+
panel.removeAttribute("hidden");
|
|
418
|
+
while (panel.firstChild) fragment.appendChild(panel.firstChild);
|
|
419
|
+
}
|
|
420
|
+
tabbed.replaceWith(fragment);
|
|
421
|
+
}
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
function neutralizeEnrichersInCode(doc) {
|
|
425
|
+
const codes = doc.querySelectorAll("code, pre");
|
|
426
|
+
if (codes.length === 0) return false;
|
|
427
|
+
const ZWS = "\u200B";
|
|
428
|
+
let touched = false;
|
|
429
|
+
for (const el of codes) {
|
|
430
|
+
const walker = doc.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
431
|
+
let node;
|
|
432
|
+
while (node = walker.nextNode()) {
|
|
433
|
+
const orig = node.nodeValue;
|
|
434
|
+
if (orig.indexOf("@") < 0) continue;
|
|
435
|
+
const next = orig.replace(/@/g, "@" + ZWS);
|
|
436
|
+
if (next !== orig) {
|
|
437
|
+
node.nodeValue = next;
|
|
438
|
+
touched = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return touched;
|
|
443
|
+
}
|
|
444
|
+
async function rewriteBasesCardLinks(doc, vaultId, index) {
|
|
445
|
+
const cards = doc.querySelectorAll("a.bases-card[href]");
|
|
446
|
+
if (cards.length === 0) return false;
|
|
447
|
+
let touched = false;
|
|
448
|
+
for (const a of cards) {
|
|
449
|
+
const href = a.getAttribute("href") || "";
|
|
450
|
+
if (!href.startsWith("/")) continue;
|
|
451
|
+
const path = logicalPathFromHref(href);
|
|
452
|
+
if (!index.paths.has(path)) continue;
|
|
453
|
+
const eId = await entryId(vaultId, path);
|
|
454
|
+
const pId = index.idOverrides?.get(path) ?? await pageId(vaultId, path);
|
|
455
|
+
a.classList.add("content-link");
|
|
456
|
+
a.setAttribute("data-uuid", `JournalEntry.${eId}.JournalEntryPage.${pId}`);
|
|
457
|
+
a.removeAttribute("href");
|
|
458
|
+
touched = true;
|
|
459
|
+
}
|
|
460
|
+
return touched;
|
|
461
|
+
}
|
|
462
|
+
function wrapRestrictedCalloutsAsSecret(doc, vault) {
|
|
463
|
+
if (!vault?.dmRole || !Array.isArray(vault.knownRoles) || vault.knownRoles.length === 0) {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
const dmIdx = vault.knownRoles.indexOf(vault.dmRole);
|
|
467
|
+
if (dmIdx < 0) return false;
|
|
468
|
+
const restrictedRoles = vault.knownRoles.slice(dmIdx);
|
|
469
|
+
if (restrictedRoles.length === 0) return false;
|
|
470
|
+
let touched = false;
|
|
471
|
+
for (const role of restrictedRoles) {
|
|
472
|
+
for (const el of doc.querySelectorAll(".callout.callout-" + cssEscape(role))) {
|
|
473
|
+
const section = doc.createElement("section");
|
|
474
|
+
section.className = "secret";
|
|
475
|
+
el.parentNode.insertBefore(section, el);
|
|
476
|
+
section.appendChild(el);
|
|
477
|
+
touched = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return touched;
|
|
481
|
+
}
|
|
482
|
+
function cssEscape(s) {
|
|
483
|
+
return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
|
484
|
+
}
|
|
485
|
+
async function rewriteWikilinks(vaultId, html, index) {
|
|
486
|
+
const matches = [];
|
|
487
|
+
let m;
|
|
488
|
+
ANCHOR_RE.lastIndex = 0;
|
|
489
|
+
while ((m = ANCHOR_RE.exec(html)) !== null) {
|
|
490
|
+
const [full, attrs, inner] = m;
|
|
491
|
+
const cls = ATTR_CLASS_RE.exec(attrs)?.[1] || "";
|
|
492
|
+
if (!/\binternal-link\b/.test(cls)) continue;
|
|
493
|
+
const label = stripTags(inner);
|
|
494
|
+
const isUnresolved = /\bis-unresolved\b/.test(cls);
|
|
495
|
+
const href = ATTR_HREF_RE.exec(attrs)?.[1] || "";
|
|
496
|
+
const path = href.startsWith("/") ? logicalPathFromHref(href) : null;
|
|
497
|
+
const inIndex = path != null && index.paths.has(path);
|
|
498
|
+
if (isUnresolved || !inIndex) {
|
|
499
|
+
matches.push({ idx: m.index, length: full.length, kind: "broken", label });
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
matches.push({ idx: m.index, length: full.length, kind: "uuid", label, path });
|
|
503
|
+
}
|
|
504
|
+
const uuidMatches = matches.filter((r) => r.kind === "uuid");
|
|
505
|
+
const resolved = await Promise.all(uuidMatches.map(async (r) => ({
|
|
506
|
+
eId: await entryId(vaultId, r.path),
|
|
507
|
+
pId: index.idOverrides?.get(r.path) ?? await pageId(vaultId, r.path)
|
|
508
|
+
})));
|
|
509
|
+
uuidMatches.forEach((r, i) => {
|
|
510
|
+
r.eId = resolved[i].eId;
|
|
511
|
+
r.pId = resolved[i].pId;
|
|
512
|
+
});
|
|
513
|
+
matches.sort((a, b) => b.idx - a.idx);
|
|
514
|
+
for (const r of matches) {
|
|
515
|
+
const replacement = r.kind === "uuid" ? `@UUID[JournalEntry.${r.eId}.JournalEntryPage.${r.pId}]{${escapeBraces(r.label)}}` : `<span class="vaults-broken">${escapeHtml(r.label)}</span>`;
|
|
516
|
+
html = html.slice(0, r.idx) + replacement + html.slice(r.idx + r.length);
|
|
517
|
+
}
|
|
518
|
+
return html;
|
|
519
|
+
}
|
|
520
|
+
function rewriteMediaSrcs(vaultId, html) {
|
|
521
|
+
return html.replace(MEDIA_SRC_RE, (full, tag, before, src, after) => {
|
|
522
|
+
if (!src.startsWith("/")) return full;
|
|
523
|
+
const path = decodeURIComponent(src.replace(/^\//, ""));
|
|
524
|
+
if (!CACHED_EXT_RE.test(path)) return full;
|
|
525
|
+
return `<${tag}${before}src="${escapeAttr(localFileUrl(vaultId, path))}"${after}>`;
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
function rewritePassthroughLinks(vaultId, html) {
|
|
529
|
+
return html.replace(ANCHOR_RE, (full, attrs, inner) => {
|
|
530
|
+
const cls = ATTR_CLASS_RE.exec(attrs)?.[1] || "";
|
|
531
|
+
if (!/\bpassthrough-link\b/.test(cls)) return full;
|
|
532
|
+
const href = ATTR_HREF_RE.exec(attrs)?.[1] || "";
|
|
533
|
+
if (!href.startsWith("/")) return full;
|
|
534
|
+
const path = decodeURIComponent(href.replace(/^\//, ""));
|
|
535
|
+
if (!CACHED_EXT_RE.test(path)) return full;
|
|
536
|
+
const newAttrs = attrs.replace(ATTR_HREF_RE, `href="${escapeAttr(localFileUrl(vaultId, path))}"`);
|
|
537
|
+
return `<a${newAttrs}>${inner}</a>`;
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function stripTags(s) {
|
|
541
|
+
return s.replace(TAG_RE, "").trim();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ../foundry/scripts/importer.mjs
|
|
545
|
+
var INDEX_BASENAME = "index";
|
|
546
|
+
var NON_INDEX_SORT_BASE = 1e5;
|
|
547
|
+
var KNOWN_INSTANCE_DOC_TYPES = [
|
|
548
|
+
"Actor",
|
|
549
|
+
"Item",
|
|
550
|
+
"Scene",
|
|
551
|
+
"JournalEntry",
|
|
552
|
+
"RollTable",
|
|
553
|
+
"Macro",
|
|
554
|
+
"Cards",
|
|
555
|
+
"Playlist"
|
|
556
|
+
];
|
|
557
|
+
function inferInstanceDocName(base) {
|
|
558
|
+
if (typeof base !== "string" || !base) return null;
|
|
559
|
+
const raw = base.includes(".") ? base.split(".").at(-2) : base.split(":")[0];
|
|
560
|
+
return KNOWN_INSTANCE_DOC_TYPES.find((t) => t.toLowerCase() === raw?.toLowerCase()) ?? null;
|
|
561
|
+
}
|
|
562
|
+
async function ensureFolderChain(vault, segments) {
|
|
563
|
+
const rootName = vault.rootFolder || vault.label || "Vault";
|
|
564
|
+
const rootKey = `${vault.id}/__root__/${rootName}`;
|
|
565
|
+
const rootFId = await folderId(vault.id, rootKey);
|
|
566
|
+
await upsertFolder(rootFId, rootName, null);
|
|
567
|
+
let parentId = rootFId;
|
|
568
|
+
let acc = rootKey;
|
|
569
|
+
for (const seg of segments) {
|
|
570
|
+
acc += "/" + seg;
|
|
571
|
+
const fId = await folderId(vault.id, acc);
|
|
572
|
+
await upsertFolder(fId, seg, parentId);
|
|
573
|
+
parentId = fId;
|
|
574
|
+
}
|
|
575
|
+
return parentId;
|
|
576
|
+
}
|
|
577
|
+
async function upsertFolder(id, name, parentId) {
|
|
578
|
+
const existing = game.folders.get(id);
|
|
579
|
+
if (existing) {
|
|
580
|
+
if (existing.name !== name || existing.folder?.id !== parentId) {
|
|
581
|
+
await existing.update({ name, folder: parentId });
|
|
582
|
+
}
|
|
583
|
+
return existing;
|
|
584
|
+
}
|
|
585
|
+
return Folder.create({ _id: id, name, type: "JournalEntry", folder: parentId }, { keepId: true });
|
|
586
|
+
}
|
|
587
|
+
function buildFolderInfo(mdPaths) {
|
|
588
|
+
const map = /* @__PURE__ */ new Map();
|
|
589
|
+
const ensure = (folderPath) => {
|
|
590
|
+
let info = map.get(folderPath);
|
|
591
|
+
if (!info) {
|
|
592
|
+
const segs = folderPath ? folderPath.split("/") : [];
|
|
593
|
+
info = { hasSubfolders: false, displayName: segs[segs.length - 1] || "" };
|
|
594
|
+
map.set(folderPath, info);
|
|
595
|
+
}
|
|
596
|
+
return info;
|
|
597
|
+
};
|
|
598
|
+
ensure("");
|
|
599
|
+
for (const p of mdPaths) {
|
|
600
|
+
const folderPath = folderOfPath(p);
|
|
601
|
+
ensure(folderPath);
|
|
602
|
+
const segs = folderPath ? folderPath.split("/") : [];
|
|
603
|
+
for (let i = 0; i < segs.length; i++) {
|
|
604
|
+
ensure(segs.slice(0, i).join("/")).hasSubfolders = true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return map;
|
|
608
|
+
}
|
|
609
|
+
function isIndexFile(filename) {
|
|
610
|
+
return filename.replace(/\.md$/i, "") === INDEX_BASENAME;
|
|
611
|
+
}
|
|
612
|
+
async function upsertFile(vault, path, body, index, meta, folderInfo) {
|
|
613
|
+
let html = await transformHtmlForFoundry(vault, body, index);
|
|
614
|
+
html = await appendInstanceDocLink(html, vault, path, meta);
|
|
615
|
+
const segs = path.split("/");
|
|
616
|
+
const filename = segs.pop();
|
|
617
|
+
const folderPath = segs.join("/");
|
|
618
|
+
const fInfo = folderInfo?.get(folderPath);
|
|
619
|
+
const leaf = !!fInfo && folderPath !== "" && !fInfo.hasSubfolders;
|
|
620
|
+
const hostSegs = leaf ? segs.slice(0, -1) : segs;
|
|
621
|
+
const folderFId = await ensureFolderChain(vault, hostSegs);
|
|
622
|
+
const entryName = folderPath === "" ? vault.rootFolder || vault.label || "Vault" : fInfo?.displayName || segs[segs.length - 1] || "";
|
|
623
|
+
const pageName = meta?.title || filename.replace(/\.md$/i, "");
|
|
624
|
+
const eId = await entryId(vault.id, path);
|
|
625
|
+
const idOverride = meta?.foundry?.id;
|
|
626
|
+
const pId = typeof idOverride === "string" && idOverride ? idOverride : await pageId(vault.id, path);
|
|
627
|
+
const pageOwnership = pageOwnershipLevelFor(vault, meta?.role);
|
|
628
|
+
const flags = { [MODULE_ID]: { vaultId: vault.id, path } };
|
|
629
|
+
const pageData = {
|
|
630
|
+
_id: pId,
|
|
631
|
+
name: pageName,
|
|
632
|
+
type: "text",
|
|
633
|
+
text: {
|
|
634
|
+
content: html,
|
|
635
|
+
format: 1
|
|
636
|
+
/* HTML */
|
|
637
|
+
},
|
|
638
|
+
sort: isIndexFile(filename) ? 0 : NON_INDEX_SORT_BASE,
|
|
639
|
+
flags,
|
|
640
|
+
// Ownership is set on the *page*, not the parent entry: in the
|
|
641
|
+
// folder-as-entry model a single entry can host pages of different
|
|
642
|
+
// tiers, and entry-level ownership would leak DM pages to players via
|
|
643
|
+
// the default INHERIT on sibling pages. The entry's own ownership is
|
|
644
|
+
// reconciled separately as the max of its pages' levels (so the entry
|
|
645
|
+
// remains visible in the sidebar to anyone who can see at least one
|
|
646
|
+
// page in it). See reconcileOwnership below.
|
|
647
|
+
...pageOwnership !== null ? { ownership: { default: pageOwnership } } : {}
|
|
648
|
+
};
|
|
649
|
+
const existing = game.journal.get(eId);
|
|
650
|
+
if (existing) {
|
|
651
|
+
const entryPatch = {};
|
|
652
|
+
if (existing.name !== entryName) entryPatch.name = entryName;
|
|
653
|
+
if (existing.folder?.id !== folderFId) entryPatch.folder = folderFId;
|
|
654
|
+
if (Object.keys(entryPatch).length > 0) await existing.update(entryPatch);
|
|
655
|
+
const existingPage = existing.pages.get(pId);
|
|
656
|
+
if (existingPage) await existingPage.update(pageData);
|
|
657
|
+
else await existing.createEmbeddedDocuments("JournalEntryPage", [pageData], { keepId: true });
|
|
658
|
+
return "modified";
|
|
659
|
+
}
|
|
660
|
+
await JournalEntry.create({
|
|
661
|
+
_id: eId,
|
|
662
|
+
name: entryName,
|
|
663
|
+
folder: folderFId,
|
|
664
|
+
pages: [pageData],
|
|
665
|
+
flags,
|
|
666
|
+
// Bootstrap the entry visible at the page's tier so player-visible
|
|
667
|
+
// pages aren't hidden in the gap between create and the post-sync
|
|
668
|
+
// reconcileOwnership pass. Mixed-tier folders converge to max(pages)
|
|
669
|
+
// via reconcile.
|
|
670
|
+
...pageOwnership !== null ? { ownership: { default: pageOwnership } } : {}
|
|
671
|
+
}, { keepId: true });
|
|
672
|
+
return "added";
|
|
673
|
+
}
|
|
674
|
+
function pageOwnershipLevelFor(vault, pageRole) {
|
|
675
|
+
if (!vault.dmRole || !vault.knownRoles?.length) return null;
|
|
676
|
+
const dmIdx = vault.knownRoles.indexOf(vault.dmRole);
|
|
677
|
+
if (dmIdx < 0) return null;
|
|
678
|
+
const pageIdx = pageRole ? vault.knownRoles.indexOf(pageRole) : -1;
|
|
679
|
+
const effectiveIdx = pageIdx < 0 ? 0 : pageIdx;
|
|
680
|
+
return effectiveIdx < dmIdx ? CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER : CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE;
|
|
681
|
+
}
|
|
682
|
+
async function reconcileOwnership(vault, bodyMetaIndex) {
|
|
683
|
+
if (!vault.dmRole || !vault.knownRoles?.length) return;
|
|
684
|
+
const ours = game.journal.contents.filter(
|
|
685
|
+
(j) => j.getFlag(MODULE_ID, "vaultId") === vault.id
|
|
686
|
+
);
|
|
687
|
+
for (const entry of ours) {
|
|
688
|
+
let entryMax = null;
|
|
689
|
+
for (const page of entry.pages.contents) {
|
|
690
|
+
const pPath = page.getFlag(MODULE_ID, "path");
|
|
691
|
+
if (!pPath) continue;
|
|
692
|
+
const bodyPath = pPath.replace(/\.md$/i, ".body.html");
|
|
693
|
+
const meta = bodyMetaIndex.get(bodyPath);
|
|
694
|
+
if (!meta) continue;
|
|
695
|
+
const level = pageOwnershipLevelFor(vault, meta.role);
|
|
696
|
+
if (level === null) continue;
|
|
697
|
+
if (page.ownership?.default !== level) {
|
|
698
|
+
try {
|
|
699
|
+
await page.update({ ownership: { default: level } });
|
|
700
|
+
} catch (err) {
|
|
701
|
+
console.warn(`Vaults | reconcile ownership ${pPath} \u2192 ${level} failed:`, err);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (entryMax === null || level > entryMax) entryMax = level;
|
|
705
|
+
}
|
|
706
|
+
if (entryMax !== null && entry.ownership?.default !== entryMax) {
|
|
707
|
+
try {
|
|
708
|
+
await entry.update({ ownership: { default: entryMax } });
|
|
709
|
+
} catch (err) {
|
|
710
|
+
console.warn(`Vaults | reconcile entry ownership for ${entry.name} \u2192 ${entryMax} failed:`, err);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async function reconcileEntryPlacement(vault, folderInfo) {
|
|
716
|
+
const ours = game.journal.contents.filter(
|
|
717
|
+
(j) => j.getFlag(MODULE_ID, "vaultId") === vault.id
|
|
718
|
+
);
|
|
719
|
+
for (const entry of ours) {
|
|
720
|
+
const firstPage = entry.pages.contents[0];
|
|
721
|
+
const path = firstPage?.getFlag(MODULE_ID, "path");
|
|
722
|
+
if (!path) continue;
|
|
723
|
+
const segs = path.split("/");
|
|
724
|
+
segs.pop();
|
|
725
|
+
const folderPath = segs.join("/");
|
|
726
|
+
const fInfo = folderInfo?.get(folderPath);
|
|
727
|
+
const leaf = !!fInfo && folderPath !== "" && !fInfo.hasSubfolders;
|
|
728
|
+
const hostSegs = leaf ? segs.slice(0, -1) : segs;
|
|
729
|
+
const expectedFolderId = await ensureFolderChain(vault, hostSegs);
|
|
730
|
+
if (entry.folder?.id !== expectedFolderId) {
|
|
731
|
+
try {
|
|
732
|
+
await entry.update({ folder: expectedFolderId });
|
|
733
|
+
} catch (err) {
|
|
734
|
+
console.warn(`Vaults | re-place ${path} \u2192 ${expectedFolderId} failed:`, err);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async function deleteFile(vault, path) {
|
|
740
|
+
const eId = await entryId(vault.id, path);
|
|
741
|
+
const pId = await pageId(vault.id, path);
|
|
742
|
+
const entry = game.journal.get(eId);
|
|
743
|
+
if (!entry) return;
|
|
744
|
+
const page = entry.pages.get(pId);
|
|
745
|
+
if (page) await page.delete();
|
|
746
|
+
if (entry.pages.size === 0) await entry.delete();
|
|
747
|
+
}
|
|
748
|
+
async function deleteVaultJournals(vaultId) {
|
|
749
|
+
const journals = game.journal.contents.filter((j) => j.getFlag(MODULE_ID, "vaultId") === vaultId);
|
|
750
|
+
for (const j of journals) await j.delete();
|
|
751
|
+
const folders = game.folders.contents.filter((f) => f.type === "JournalEntry");
|
|
752
|
+
for (const f of folders.reverse()) {
|
|
753
|
+
if (f.contents.length === 0 && f.children.length === 0) {
|
|
754
|
+
const root = await folderId(vaultId, `${vaultId}/__root__/${f.name}`);
|
|
755
|
+
if (f.id === root) await f.delete();
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
async function appendInstanceDocLink(html, vault, path, meta) {
|
|
760
|
+
const base = meta?.foundry?.base;
|
|
761
|
+
const docName = inferInstanceDocName(base);
|
|
762
|
+
if (!docName) return html;
|
|
763
|
+
const idOverride = meta?.foundry?.id;
|
|
764
|
+
const docId = typeof idOverride === "string" && idOverride ? idOverride : await instanceId(vault.id, path);
|
|
765
|
+
const label = meta?.title || path.split("/").pop().replace(/\.md$/i, "");
|
|
766
|
+
return html + `
|
|
767
|
+
<p class="vaults-instance-link"><em>Foundry document:</em> @UUID[${docName}.${docId}]{${escapeBraces(label)}}</p>`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ../foundry/scripts/instance.mjs
|
|
771
|
+
var DESCRIPTION_FIELDS = {
|
|
772
|
+
dnd5e: {
|
|
773
|
+
Actor: "system.details.biography.value",
|
|
774
|
+
Item: "system.description.value"
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
var CLONE_SUPPORTED_DOCS = /* @__PURE__ */ new Set(["Actor", "Item"]);
|
|
778
|
+
var BLANK_DOC_TYPES = /* @__PURE__ */ new Set([
|
|
779
|
+
"Actor",
|
|
780
|
+
"Item",
|
|
781
|
+
"Scene",
|
|
782
|
+
"JournalEntry",
|
|
783
|
+
"RollTable",
|
|
784
|
+
"Macro",
|
|
785
|
+
"Cards",
|
|
786
|
+
"Playlist"
|
|
787
|
+
]);
|
|
788
|
+
var COLLECTION_FOR = {
|
|
789
|
+
Actor: () => game.actors,
|
|
790
|
+
Item: () => game.items,
|
|
791
|
+
Scene: () => game.scenes,
|
|
792
|
+
JournalEntry: () => game.journal,
|
|
793
|
+
RollTable: () => game.tables,
|
|
794
|
+
Macro: () => game.macros,
|
|
795
|
+
Cards: () => game.cards,
|
|
796
|
+
Playlist: () => game.playlists
|
|
797
|
+
};
|
|
798
|
+
async function applyInstance(vault, vaultPath, meta) {
|
|
799
|
+
const fm = meta?.foundry;
|
|
800
|
+
if (!fm || typeof fm !== "object") return;
|
|
801
|
+
const parsed = parseFoundryBase(fm.base);
|
|
802
|
+
if (!parsed) return;
|
|
803
|
+
let docName;
|
|
804
|
+
let baseData;
|
|
805
|
+
if (parsed.kind === "uuid") {
|
|
806
|
+
const template = await safeFromUuid(parsed.uuid);
|
|
807
|
+
if (!template) {
|
|
808
|
+
console.warn(`Vaults | foundry.base: ${vaultPath} \u2192 ${parsed.uuid} did not resolve; skipping.`);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
docName = template.documentName;
|
|
812
|
+
if (!CLONE_SUPPORTED_DOCS.has(docName)) {
|
|
813
|
+
console.warn(
|
|
814
|
+
`Vaults | foundry.base: ${vaultPath} \u2192 ${parsed.uuid} is a ${docName}; clone-from-UUID only supports ${[...CLONE_SUPPORTED_DOCS].join(", ")}.`
|
|
815
|
+
);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
baseData = template.toObject();
|
|
820
|
+
} catch (err) {
|
|
821
|
+
console.warn(`Vaults | foundry.base: could not read template ${parsed.uuid}:`, err);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
delete baseData._id;
|
|
825
|
+
} else {
|
|
826
|
+
docName = parsed.docName;
|
|
827
|
+
baseData = parsed.subtype ? { type: parsed.subtype } : {};
|
|
828
|
+
}
|
|
829
|
+
const collection = COLLECTION_FOR[docName]?.();
|
|
830
|
+
if (!collection) {
|
|
831
|
+
console.warn(`Vaults | foundry.base: no world collection for ${docName}; skipping ${vaultPath}.`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const docClass = CONFIG[docName].documentClass;
|
|
835
|
+
const id = typeof fm.id === "string" && fm.id ? fm.id : await instanceId(vault.id, vaultPath);
|
|
836
|
+
const dataJson = fm.data_json && typeof fm.data_json === "object" && !Array.isArray(fm.data_json) ? rewriteVaultPaths(structuredClone(fm.data_json), vault.id) : null;
|
|
837
|
+
if (dataJson) await ensureEmbeddedIds(dataJson, vault.id, vaultPath);
|
|
838
|
+
const overlay = await buildOverlay(vault, vaultPath, meta, docName);
|
|
839
|
+
const existing = collection.get(id);
|
|
840
|
+
if (existing) {
|
|
841
|
+
const updatePatch = dataJson ? deepMerge(structuredClone(dataJson), overlay) : overlay;
|
|
842
|
+
try {
|
|
843
|
+
await existing.update(updatePatch);
|
|
844
|
+
} catch (err) {
|
|
845
|
+
console.warn(`Vaults | foundry.base update failed for ${vaultPath}:`, err);
|
|
846
|
+
}
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (dataJson) deepMerge(baseData, dataJson);
|
|
850
|
+
baseData._id = id;
|
|
851
|
+
deepMerge(baseData, overlay);
|
|
852
|
+
try {
|
|
853
|
+
await docClass.create(baseData, { keepId: true, keepEmbeddedIds: true });
|
|
854
|
+
} catch (err) {
|
|
855
|
+
console.warn(`Vaults | foundry.base create failed for ${vaultPath}:`, err);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (docName === "Scene") {
|
|
859
|
+
const created = collection.get(id);
|
|
860
|
+
if (created && !created.thumb) {
|
|
861
|
+
try {
|
|
862
|
+
const { thumb } = await created.createThumbnail();
|
|
863
|
+
if (thumb) await created.update({ thumb });
|
|
864
|
+
} catch (err) {
|
|
865
|
+
console.warn(`Vaults | scene thumbnail generation failed for ${vaultPath}:`, err);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
function parseFoundryBase(spec) {
|
|
871
|
+
if (typeof spec !== "string" || !spec) return null;
|
|
872
|
+
if (spec.includes(".")) return { kind: "uuid", uuid: spec };
|
|
873
|
+
const [typeRaw, subtype] = spec.split(":");
|
|
874
|
+
const docName = [...BLANK_DOC_TYPES].find((t) => t.toLowerCase() === typeRaw.toLowerCase());
|
|
875
|
+
if (!docName) return null;
|
|
876
|
+
return { kind: "blank", docName, subtype: subtype || void 0 };
|
|
877
|
+
}
|
|
878
|
+
async function deleteInstance(vault, vaultPath) {
|
|
879
|
+
const id = await instanceId(vault.id, vaultPath);
|
|
880
|
+
for (const getCollection of Object.values(COLLECTION_FOR)) {
|
|
881
|
+
const collection = getCollection();
|
|
882
|
+
const doc = collection?.get(id);
|
|
883
|
+
if (!doc) continue;
|
|
884
|
+
if (doc.getFlag(MODULE_ID, "vaultId") !== vault.id) continue;
|
|
885
|
+
try {
|
|
886
|
+
await doc.delete();
|
|
887
|
+
} catch (err) {
|
|
888
|
+
console.warn(`Vaults | failed to delete ${doc.documentName} for ${vaultPath}:`, err);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async function deleteVaultInstances(vaultId) {
|
|
893
|
+
for (const [docName, getCollection] of Object.entries(COLLECTION_FOR)) {
|
|
894
|
+
const collection = getCollection();
|
|
895
|
+
if (!collection) continue;
|
|
896
|
+
const ours = collection.contents.filter((d) => d.getFlag(MODULE_ID, "vaultId") === vaultId);
|
|
897
|
+
for (const doc of ours) {
|
|
898
|
+
try {
|
|
899
|
+
await doc.delete();
|
|
900
|
+
} catch (err) {
|
|
901
|
+
console.warn(`Vaults | failed to delete ${docName} ${doc.id}:`, err);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
for (const docName of BLANK_DOC_TYPES) {
|
|
906
|
+
const fId = await instanceFolderId(vaultId, docName);
|
|
907
|
+
const folder = game.folders.get(fId);
|
|
908
|
+
if (!folder || folder.type !== docName) continue;
|
|
909
|
+
if (folder.contents.length > 0 || folder.children.length > 0) continue;
|
|
910
|
+
try {
|
|
911
|
+
await folder.delete();
|
|
912
|
+
} catch (err) {
|
|
913
|
+
console.warn(`Vaults | failed to delete ${docName} folder:`, err);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
async function instanceFolderId(vaultId, docName) {
|
|
918
|
+
return folderId(vaultId, `${vaultId}/__instance__/${docName}`);
|
|
919
|
+
}
|
|
920
|
+
async function ensureInstanceFolder(vault, docName) {
|
|
921
|
+
const fId = await instanceFolderId(vault.id, docName);
|
|
922
|
+
const existing = game.folders.get(fId);
|
|
923
|
+
const name = vault.rootFolder || vault.label || "Vault";
|
|
924
|
+
if (existing) {
|
|
925
|
+
if (existing.name !== name) {
|
|
926
|
+
try {
|
|
927
|
+
await existing.update({ name });
|
|
928
|
+
} catch (err) {
|
|
929
|
+
console.warn(`Vaults | could not rename ${docName} folder for ${vault.label}:`, err);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return fId;
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
await Folder.create({ _id: fId, name, type: docName, folder: null }, { keepId: true });
|
|
936
|
+
return fId;
|
|
937
|
+
} catch (err) {
|
|
938
|
+
console.warn(`Vaults | could not create ${docName} folder for ${vault.label}:`, err);
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
async function buildOverlay(vault, vaultPath, meta, docName) {
|
|
943
|
+
const overlay = {
|
|
944
|
+
// Prefer the page's frontmatter `title:` over the filename — the wiki
|
|
945
|
+
// already treats title as the page's display name, and a doc named
|
|
946
|
+
// "Potion of Healing (Mossfoot Brew)" reads better in the Foundry
|
|
947
|
+
// sidebar than "Healing Potion".
|
|
948
|
+
name: meta.title || baseName(vaultPath),
|
|
949
|
+
folder: await ensureInstanceFolder(vault, docName),
|
|
950
|
+
flags: { [MODULE_ID]: { vaultId: vault.id, path: vaultPath } }
|
|
951
|
+
};
|
|
952
|
+
if (meta.image) {
|
|
953
|
+
const localImg = imageUrlFromMeta(vault.id, meta.image);
|
|
954
|
+
if (localImg) {
|
|
955
|
+
overlay.img = localImg;
|
|
956
|
+
if (docName === "Actor") setPath(overlay, "prototypeToken.texture.src", localImg);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const fm = meta?.foundry;
|
|
960
|
+
const descPath = DESCRIPTION_FIELDS[game.system.id]?.[docName];
|
|
961
|
+
const embedAuto = fm?.embed !== false;
|
|
962
|
+
if (descPath && embedAuto) {
|
|
963
|
+
const eId = await entryId(vault.id, vaultPath);
|
|
964
|
+
const pId = await pageId(vault.id, vaultPath);
|
|
965
|
+
setPath(overlay, descPath, `<p>@Embed[JournalEntry.${eId}.JournalEntryPage.${pId} inline]</p>`);
|
|
966
|
+
}
|
|
967
|
+
if (fm?.data && typeof fm.data === "object") {
|
|
968
|
+
const cloned = rewriteVaultPaths(structuredClone(fm.data), vault.id);
|
|
969
|
+
await ensureEmbeddedIds(cloned, vault.id, vaultPath);
|
|
970
|
+
deepMerge(overlay, cloned);
|
|
971
|
+
}
|
|
972
|
+
if (docName === "Scene") {
|
|
973
|
+
const note = await buildJournalNote(vault, vaultPath, meta);
|
|
974
|
+
if (note) overlay.notes = [...overlay.notes ?? [], note];
|
|
975
|
+
}
|
|
976
|
+
return overlay;
|
|
977
|
+
}
|
|
978
|
+
async function buildJournalNote(vault, vaultPath, meta) {
|
|
979
|
+
const fm = meta?.foundry ?? {};
|
|
980
|
+
const cfg = { ...fm.data_json ?? {}, ...fm.data ?? {} };
|
|
981
|
+
const width = Number(cfg.width) || 4e3;
|
|
982
|
+
const height = Number(cfg.height) || 3e3;
|
|
983
|
+
const padding = Number(cfg.padding ?? 0.25);
|
|
984
|
+
const gridSize = Number(cfg.grid?.size) || 100;
|
|
985
|
+
const iconSize = gridSize;
|
|
986
|
+
const eId = await entryId(vault.id, vaultPath);
|
|
987
|
+
const idOverride = meta?.foundry?.id;
|
|
988
|
+
const pId = typeof idOverride === "string" && idOverride ? idOverride : await pageId(vault.id, vaultPath);
|
|
989
|
+
return {
|
|
990
|
+
_id: await subdocId(vault.id, vaultPath, "/notes/__journalLink__"),
|
|
991
|
+
entryId: eId,
|
|
992
|
+
pageId: pId,
|
|
993
|
+
x: gridSize * (Math.ceil(width / gridSize * padding) - 0.5),
|
|
994
|
+
y: gridSize * (Math.ceil(height / gridSize * padding) + 0.5),
|
|
995
|
+
iconSize,
|
|
996
|
+
texture: {
|
|
997
|
+
src: "icons/svg/book.svg",
|
|
998
|
+
anchorX: 0.5,
|
|
999
|
+
anchorY: 0.5,
|
|
1000
|
+
fit: "contain",
|
|
1001
|
+
tint: "#ffffff"
|
|
1002
|
+
},
|
|
1003
|
+
text: ""
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
var VALID_SUBDOC_ID = /^[A-Za-z0-9]{16}$/;
|
|
1007
|
+
async function ensureEmbeddedIds(value, vaultId, pagePath, ptr = "") {
|
|
1008
|
+
if (Array.isArray(value)) {
|
|
1009
|
+
for (let i = 0; i < value.length; i++) {
|
|
1010
|
+
const childPtr = `${ptr}/${i}`;
|
|
1011
|
+
const item = value[i];
|
|
1012
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
1013
|
+
if (typeof item._id !== "string" || !VALID_SUBDOC_ID.test(item._id)) {
|
|
1014
|
+
item._id = await subdocId(vaultId, pagePath, childPtr);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
await ensureEmbeddedIds(item, vaultId, pagePath, childPtr);
|
|
1018
|
+
}
|
|
1019
|
+
} else if (value && typeof value === "object") {
|
|
1020
|
+
for (const k of Object.keys(value)) {
|
|
1021
|
+
await ensureEmbeddedIds(value[k], vaultId, pagePath, `${ptr}/${k}`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
function rewriteVaultPaths(value, vaultId) {
|
|
1026
|
+
if (typeof value === "string") return rewriteVaultString(value, vaultId);
|
|
1027
|
+
if (Array.isArray(value)) {
|
|
1028
|
+
for (let i = 0; i < value.length; i++) value[i] = rewriteVaultPaths(value[i], vaultId);
|
|
1029
|
+
return value;
|
|
1030
|
+
}
|
|
1031
|
+
if (value && typeof value === "object") {
|
|
1032
|
+
for (const k of Object.keys(value)) value[k] = rewriteVaultPaths(value[k], vaultId);
|
|
1033
|
+
return value;
|
|
1034
|
+
}
|
|
1035
|
+
return value;
|
|
1036
|
+
}
|
|
1037
|
+
function rewriteVaultString(s, vaultId) {
|
|
1038
|
+
if (!s.startsWith("@vault/")) return s;
|
|
1039
|
+
const vaultPath = s.slice("@vault/".length);
|
|
1040
|
+
if (!vaultPath) return s;
|
|
1041
|
+
return localFileUrl(vaultId, vaultPath);
|
|
1042
|
+
}
|
|
1043
|
+
async function safeFromUuid(uuid) {
|
|
1044
|
+
try {
|
|
1045
|
+
return await fromUuid(uuid);
|
|
1046
|
+
} catch {
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
function baseName(path) {
|
|
1051
|
+
return path.split("/").pop().replace(/\.md$/i, "");
|
|
1052
|
+
}
|
|
1053
|
+
function imageUrlFromMeta(vaultId, image) {
|
|
1054
|
+
if (/^https?:\/\//i.test(image)) return image;
|
|
1055
|
+
const vaultPath = decodeURIComponent(image.replace(/^\//, ""));
|
|
1056
|
+
if (!vaultPath) return null;
|
|
1057
|
+
return localFileUrl(vaultId, vaultPath);
|
|
1058
|
+
}
|
|
1059
|
+
function setPath(obj, path, value) {
|
|
1060
|
+
const segs = path.split(".");
|
|
1061
|
+
let cursor = obj;
|
|
1062
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
1063
|
+
const seg = segs[i];
|
|
1064
|
+
if (cursor[seg] == null || typeof cursor[seg] !== "object") cursor[seg] = {};
|
|
1065
|
+
cursor = cursor[seg];
|
|
1066
|
+
}
|
|
1067
|
+
cursor[segs[segs.length - 1]] = value;
|
|
1068
|
+
return obj;
|
|
1069
|
+
}
|
|
1070
|
+
function deepMerge(target, source) {
|
|
1071
|
+
for (const [k, v] of Object.entries(source)) {
|
|
1072
|
+
if (v && typeof v === "object" && !Array.isArray(v) && target[k] && typeof target[k] === "object" && !Array.isArray(target[k])) {
|
|
1073
|
+
deepMerge(target[k], v);
|
|
1074
|
+
} else {
|
|
1075
|
+
target[k] = v;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return target;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// ../foundry/scripts/auth.mjs
|
|
1082
|
+
function tokenInfo(token) {
|
|
1083
|
+
if (!token) return null;
|
|
1084
|
+
const parts = token.split(".");
|
|
1085
|
+
if (parts.length !== 3) return null;
|
|
1086
|
+
const exp = Number(parts[1]);
|
|
1087
|
+
return {
|
|
1088
|
+
role: parts[0],
|
|
1089
|
+
expiresAt: Number.isFinite(exp) ? new Date(exp * 1e3) : null
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ../foundry/scripts/sync.mjs
|
|
1094
|
+
async function sync(host, vault, { forceFull = false } = {}) {
|
|
1095
|
+
if (!vault?.url) {
|
|
1096
|
+
host.notify("error", host.localize("VAULTS.Sync.NoUrl"));
|
|
1097
|
+
return { ok: false, refreshHandlerAssets: false };
|
|
1098
|
+
}
|
|
1099
|
+
if (vault.token) {
|
|
1100
|
+
const info = tokenInfo(vault.token);
|
|
1101
|
+
const stillValid = info?.expiresAt && info.expiresAt > /* @__PURE__ */ new Date();
|
|
1102
|
+
if (!stillValid) {
|
|
1103
|
+
await host.updateVaultEntry(vault.id, { token: "", role: "" });
|
|
1104
|
+
host.notify("warn", host.localize("VAULTS.Sync.TokenExpired", { name: vault.label }));
|
|
1105
|
+
return { ok: false, refreshHandlerAssets: false };
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
const start = Date.now();
|
|
1109
|
+
host.notify("info", host.localize("VAULTS.Sync.StartingNamed", { name: vault.label }));
|
|
1110
|
+
let manifest;
|
|
1111
|
+
try {
|
|
1112
|
+
manifest = await fetchManifest(vault);
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
host.notify("error", host.localize("VAULTS.Sync.Error", { message: err.message }));
|
|
1115
|
+
return { ok: false, refreshHandlerAssets: false };
|
|
1116
|
+
}
|
|
1117
|
+
const OUR_MANIFEST_VERSION = 1;
|
|
1118
|
+
const remoteManifestVersion = Number(manifest.manifest_version) || 0;
|
|
1119
|
+
if (remoteManifestVersion > OUR_MANIFEST_VERSION) {
|
|
1120
|
+
console.warn(
|
|
1121
|
+
`Vaults | ${vault.label}: deploy manifest_version=${remoteManifestVersion}, our module supports up to ${OUR_MANIFEST_VERSION}. Some new fields may be ignored. cli_version: ${manifest.cli_version || "(unknown)"}`
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
const isPublic = manifest.auth?.required === false;
|
|
1125
|
+
const knownRoles = Array.isArray(manifest.auth?.roles) ? manifest.auth.roles : [];
|
|
1126
|
+
const patch = {};
|
|
1127
|
+
if (vault.public !== isPublic) patch.public = isPublic;
|
|
1128
|
+
if (!arraysEqual(vault.knownRoles, knownRoles)) patch.knownRoles = knownRoles;
|
|
1129
|
+
const remoteAssets = manifest.assets?.foundry || {};
|
|
1130
|
+
const newAssetPaths = {
|
|
1131
|
+
foundryJs: remoteAssets.js || null,
|
|
1132
|
+
foundryCss: remoteAssets.css || null
|
|
1133
|
+
};
|
|
1134
|
+
if (JSON.stringify(vault.handlerAssetPaths || {}) !== JSON.stringify(newAssetPaths)) {
|
|
1135
|
+
patch.handlerAssetPaths = newAssetPaths;
|
|
1136
|
+
}
|
|
1137
|
+
if (vault.dmRole && !knownRoles.includes(vault.dmRole)) patch.dmRole = "";
|
|
1138
|
+
if (Object.keys(patch).length > 0) {
|
|
1139
|
+
await host.updateVaultEntry(vault.id, patch);
|
|
1140
|
+
Object.assign(vault, patch);
|
|
1141
|
+
}
|
|
1142
|
+
const remote = new Map(manifest.files.map((f) => [f.path, f.hash]));
|
|
1143
|
+
const lastSync = host.getVaultState(vault.id);
|
|
1144
|
+
const local = forceFull ? /* @__PURE__ */ new Map() : new Map(Object.entries(lastSync.lastManifest || {}));
|
|
1145
|
+
const bodyPaths = manifest.files.filter((f) => f.path.endsWith(".body.html")).map((f) => f.path);
|
|
1146
|
+
const pathIndex = buildPathIndex(manifest.files);
|
|
1147
|
+
const allMdPaths = bodyPaths.map((p) => p.replace(/\.body\.html$/i, ".md"));
|
|
1148
|
+
const folderInfo = buildFolderInfo(allMdPaths);
|
|
1149
|
+
const bodyMetaIndex = /* @__PURE__ */ new Map();
|
|
1150
|
+
for (const f of manifest.files) {
|
|
1151
|
+
if (f.meta && f.path.endsWith(".body.html")) bodyMetaIndex.set(f.path, f.meta);
|
|
1152
|
+
}
|
|
1153
|
+
const toUpsert = bodyPaths.filter((p) => remote.get(p) !== local.get(p));
|
|
1154
|
+
const toDelete = [...local.keys()].filter((p) => p.endsWith(".body.html") && !remote.has(p));
|
|
1155
|
+
if (forceFull) await host.setVaultState(vault.id, { lastImageManifest: {} });
|
|
1156
|
+
let imageStats = { downloaded: 0, removed: 0, errors: 0 };
|
|
1157
|
+
try {
|
|
1158
|
+
imageStats = await syncImages(host, vault, manifest.files);
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
console.warn(`Vaults | image sync failed for ${vault.label}:`, err);
|
|
1161
|
+
}
|
|
1162
|
+
if (toUpsert.length === 0 && toDelete.length === 0 && imageStats.downloaded === 0 && imageStats.removed === 0) {
|
|
1163
|
+
host.notify("info", host.localize("VAULTS.Sync.NothingToDo"));
|
|
1164
|
+
return {
|
|
1165
|
+
ok: true,
|
|
1166
|
+
refreshHandlerAssets: false,
|
|
1167
|
+
added: 0,
|
|
1168
|
+
modified: 0,
|
|
1169
|
+
removed: 0,
|
|
1170
|
+
imageStats,
|
|
1171
|
+
instances: 0
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
host.notify(
|
|
1175
|
+
"info",
|
|
1176
|
+
forceFull ? host.localize("VAULTS.Sync.Initial", { count: toUpsert.length }) : host.localize("VAULTS.Sync.Incremental", {
|
|
1177
|
+
add: toUpsert.length,
|
|
1178
|
+
mod: 0,
|
|
1179
|
+
del: toDelete.length
|
|
1180
|
+
})
|
|
1181
|
+
);
|
|
1182
|
+
let bodies;
|
|
1183
|
+
try {
|
|
1184
|
+
bodies = await fetchSourceBatch(vault, toUpsert);
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
console.error(`Vaults | batch fetch failed for ${vault.label}:`, err);
|
|
1187
|
+
host.notify("error", host.localize("VAULTS.Sync.Error", { message: err.message }));
|
|
1188
|
+
return { ok: false, refreshHandlerAssets: false };
|
|
1189
|
+
}
|
|
1190
|
+
let added = 0, modified = 0, instances = 0;
|
|
1191
|
+
for (const bodyPath of toUpsert) {
|
|
1192
|
+
const html = bodies.get(bodyPath);
|
|
1193
|
+
if (html == null) {
|
|
1194
|
+
console.warn(`Vaults | server returned no content for ${bodyPath}`);
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
const logicalPath = bodyPath.replace(/\.body\.html$/i, ".md");
|
|
1198
|
+
const pageMeta = bodyMetaIndex.get(bodyPath);
|
|
1199
|
+
try {
|
|
1200
|
+
const result = await upsertFile(vault, logicalPath, html, pathIndex, pageMeta, folderInfo);
|
|
1201
|
+
if (result === "added") added++;
|
|
1202
|
+
else modified++;
|
|
1203
|
+
if (pageMeta?.foundry?.base) {
|
|
1204
|
+
try {
|
|
1205
|
+
await applyInstance(vault, logicalPath, pageMeta);
|
|
1206
|
+
instances++;
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
console.warn(`Vaults | foundry instantiation failed for ${logicalPath}:`, err);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
console.warn(`Vaults | upsert failed for ${logicalPath}:`, err);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
let removed = 0;
|
|
1216
|
+
for (const bodyPath of toDelete) {
|
|
1217
|
+
const logicalPath = bodyPath.replace(/\.body\.html$/i, ".md");
|
|
1218
|
+
try {
|
|
1219
|
+
await deleteFile(vault, logicalPath);
|
|
1220
|
+
removed++;
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
console.warn(`Vaults | delete failed for ${logicalPath}:`, err);
|
|
1223
|
+
}
|
|
1224
|
+
try {
|
|
1225
|
+
await deleteInstance(vault, logicalPath);
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
console.warn(`Vaults | delete instance failed for ${logicalPath}:`, err);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
await host.setVaultState(vault.id, { lastManifest: Object.fromEntries(remote) });
|
|
1231
|
+
await reconcileEntryPlacement(vault, folderInfo);
|
|
1232
|
+
await reconcileOwnership(vault, bodyMetaIndex);
|
|
1233
|
+
const seconds = ((Date.now() - start) / 1e3).toFixed(1);
|
|
1234
|
+
host.notify("info", host.localize("VAULTS.Sync.Done", { added, modified, removed, seconds }));
|
|
1235
|
+
if (imageStats.downloaded > 0 || imageStats.removed > 0) {
|
|
1236
|
+
console.info(`Vaults | ${vault.label} images: ${imageStats.downloaded} downloaded, ${imageStats.removed} removed` + (imageStats.errors ? `, ${imageStats.errors} failed` : ""));
|
|
1237
|
+
}
|
|
1238
|
+
if (instances > 0) console.info(`Vaults | ${vault.label} instantiated ${instances} document(s) from page foundry.base.`);
|
|
1239
|
+
return {
|
|
1240
|
+
ok: true,
|
|
1241
|
+
refreshHandlerAssets: true,
|
|
1242
|
+
added,
|
|
1243
|
+
modified,
|
|
1244
|
+
removed,
|
|
1245
|
+
imageStats,
|
|
1246
|
+
instances
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
function arraysEqual(a, b) {
|
|
1250
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
1251
|
+
if (a.length !== b.length) return false;
|
|
1252
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ../foundry/scripts/importer-entry.mjs
|
|
1257
|
+
var REQUIRED_HOST_VERSION = 1;
|
|
1258
|
+
async function runSync(host, vault, options = {}) {
|
|
1259
|
+
return sync(host, vault, options);
|
|
1260
|
+
}
|
|
1261
|
+
async function runRemove(_host, vault) {
|
|
1262
|
+
await deleteVaultJournals(vault.id);
|
|
1263
|
+
await deleteVaultCache(vault.id);
|
|
1264
|
+
await deleteVaultInstances(vault.id);
|
|
1265
|
+
}
|
|
1266
|
+
export {
|
|
1267
|
+
REQUIRED_HOST_VERSION,
|
|
1268
|
+
runRemove,
|
|
1269
|
+
runSync
|
|
1270
|
+
};
|
package/dist/foundry-importer.js
CHANGED
|
@@ -1,50 +1,19 @@
|
|
|
1
|
-
// Build helper:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Build helper: the Foundry-side importer is pre-bundled at CLI build time
|
|
2
|
+
// (see scripts/bundle-importer.mjs) into `dist/foundry-importer.bundle.js`,
|
|
3
|
+
// which ships inside the npm package. At deploy time we copy that bundle to
|
|
4
|
+
// `_foundry/importer.js` and emit a `_foundry/version.json` sidecar carrying
|
|
5
|
+
// `{ version, sha256 }` so the Foundry host can detect skew before evaluating.
|
|
4
6
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// so the host can detect skew before evaluating.
|
|
9
|
-
import { build } from "esbuild";
|
|
7
|
+
// Bundling ahead of publish (not here) means the installed CLI never runs
|
|
8
|
+
// esbuild on the user's machine and doesn't need the foundry/ source tree,
|
|
9
|
+
// which isn't part of the npm package.
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
12
|
-
import { dirname, join
|
|
11
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { CLI_VERSION } from "./version.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* but runs against the monorepo checkout during dev / `vaults build` —
|
|
18
|
-
* walk up from this file until we find the foundry/ sibling. */
|
|
19
|
-
function resolveEntry() {
|
|
20
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
-
// dev: cli/src/ → cli → vaults/ → vaults/foundry/scripts/importer-entry.mjs
|
|
22
|
-
// installed: dist/ → cli → vaults/ → vaults/foundry/scripts/importer-entry.mjs (same)
|
|
23
|
-
return resolve(here, "..", "..", ENTRY_RELATIVE_TO_REPO);
|
|
24
|
-
}
|
|
25
|
-
async function bundleImporter() {
|
|
26
|
-
const entry = resolveEntry();
|
|
27
|
-
const result = await build({
|
|
28
|
-
entryPoints: [entry],
|
|
29
|
-
bundle: true,
|
|
30
|
-
format: "esm",
|
|
31
|
-
platform: "browser",
|
|
32
|
-
target: "es2022",
|
|
33
|
-
write: false,
|
|
34
|
-
// Foundry globals are present at runtime; esbuild treats them as
|
|
35
|
-
// externals automatically because they're not imported. No need to
|
|
36
|
-
// mark anything as external explicitly.
|
|
37
|
-
minify: false,
|
|
38
|
-
sourcemap: false,
|
|
39
|
-
legalComments: "none",
|
|
40
|
-
});
|
|
41
|
-
if (result.outputFiles.length !== 1) {
|
|
42
|
-
throw new Error(`expected 1 bundle output, got ${result.outputFiles.length}`);
|
|
43
|
-
}
|
|
44
|
-
const source = result.outputFiles[0].text;
|
|
45
|
-
const sha256 = createHash("sha256").update(source).digest("hex");
|
|
46
|
-
return { source, sha256 };
|
|
47
|
-
}
|
|
15
|
+
// Sibling of this compiled file in dist/, produced by scripts/bundle-importer.mjs.
|
|
16
|
+
const BUNDLE_PATH = join(dirname(fileURLToPath(import.meta.url)), "foundry-importer.bundle.js");
|
|
48
17
|
/**
|
|
49
18
|
* Write `_foundry/importer.js` + `_foundry/version.json` into the deploy.
|
|
50
19
|
* Called from build.ts after the variant outputs are in place — the
|
|
@@ -52,7 +21,8 @@ async function bundleImporter() {
|
|
|
52
21
|
* no role-gated content.
|
|
53
22
|
*/
|
|
54
23
|
export async function writeFoundryImporter(outputDir) {
|
|
55
|
-
const
|
|
24
|
+
const source = await readFile(BUNDLE_PATH, "utf8");
|
|
25
|
+
const sha256 = createHash("sha256").update(source).digest("hex");
|
|
56
26
|
const dir = join(outputDir, "_foundry");
|
|
57
27
|
await mkdir(dir, { recursive: true });
|
|
58
28
|
await writeFile(join(dir, "importer.js"), source);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"foundry-importer.js","sourceRoot":"","sources":["../src/foundry-importer.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"foundry-importer.js","sourceRoot":"","sources":["../src/foundry-importer.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,4EAA4E;AAC5E,4EAA4E;AAC5E,6EAA6E;AAC7E,+EAA+E;AAC/E,EAAE;AACF,0EAA0E;AAC1E,2EAA2E;AAC3E,uCAAuC;AAEvC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC9D,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE3C,mFAAmF;AACnF,MAAM,WAAW,GAAG,IAAI,CACtB,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EACvC,4BAA4B,CAC7B,CAAC;AAEF;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,SAAiB;IAC1D,MAAM,MAAM,GAAG,MAAM,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;IACnD,MAAM,MAAM,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACjE,MAAM,GAAG,GAAG,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IACxC,MAAM,KAAK,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,MAAM,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,aAAa,CAAC,EAAE,MAAM,CAAC,CAAC;IAClD,MAAM,SAAS,CACb,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,EACzB,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAC1D,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wizzlethorpe/vaults",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Sync an Obsidian vault to a Cloudflare-hosted wiki with role-based access. Renders locally, deploys to Cloudflare Pages.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"obsidian",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"typescript": "^5.9.3"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
|
-
"build": "tsc",
|
|
64
|
+
"build": "tsc && node scripts/bundle-importer.mjs",
|
|
65
65
|
"start": "node dist/index.js",
|
|
66
66
|
"test": "node --import tsx --test test/*.test.ts",
|
|
67
67
|
"typecheck:test": "tsc -p tsconfig.test.json --noEmit"
|