@thatopen/services 0.21.0 → 0.23.0

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/cli.js CHANGED
@@ -6795,6 +6795,120 @@ Object.assign(lookup, {
6795
6795
  io: lookup,
6796
6796
  connect: lookup
6797
6797
  });
6798
+ const DOCUMENT_SCHEMA = "thatopen/document@1";
6799
+ const DOCUMENT_FILE = "document.json";
6800
+ const versionTag$1 = () => `v-${Date.now()}`;
6801
+ function idOf$1(item) {
6802
+ return String(item._id ?? item.id ?? "");
6803
+ }
6804
+ class ProjectDocuments {
6805
+ constructor(client, projectId) {
6806
+ this.client = client;
6807
+ this.projectId = projectId;
6808
+ }
6809
+ /**
6810
+ * The document's descriptor, read and validated, or null when the folder
6811
+ * has none yet (an old folder, or a plain folder that is not a document).
6812
+ */
6813
+ async descriptor(folderId) {
6814
+ const file = await this.#descriptorFile(folderId);
6815
+ if (!file) return null;
6816
+ const response = await this.client.downloadFile(file.id);
6817
+ const text2 = await response.text();
6818
+ if (!text2.trim()) return null;
6819
+ let parsed;
6820
+ try {
6821
+ parsed = JSON.parse(text2);
6822
+ } catch {
6823
+ return null;
6824
+ }
6825
+ return normalize$1(parsed);
6826
+ }
6827
+ /**
6828
+ * The first view whose format appears in `formats` — searched in the ORDER
6829
+ * OF THE LIST, so the caller's preference wins (ask `['frag', 'ifc']` and a
6830
+ * document carrying both answers the frag). Null when none matches: that is
6831
+ * where an interface offers "Process".
6832
+ */
6833
+ async view(folderId, formats2) {
6834
+ const descriptor = await this.descriptor(folderId);
6835
+ if (!descriptor) return null;
6836
+ for (const format2 of formats2) {
6837
+ const match = descriptor.views.find((v) => v.format === format2);
6838
+ if (match) return match;
6839
+ }
6840
+ return null;
6841
+ }
6842
+ /**
6843
+ * Registers a renderable representation — a processor's last act. One view
6844
+ * per format: an existing view of the same format is replaced (a re-run
6845
+ * processor updates, it does not accumulate). The descriptor must exist;
6846
+ * create it first with {@link setDescriptor}.
6847
+ */
6848
+ async addView(folderId, view) {
6849
+ const descriptor = await this.descriptor(folderId);
6850
+ if (!descriptor) {
6851
+ throw new Error(
6852
+ "This folder has no document descriptor yet: call setDescriptor first."
6853
+ );
6854
+ }
6855
+ descriptor.views = [
6856
+ ...descriptor.views.filter((v) => v.format !== view.format),
6857
+ view
6858
+ ];
6859
+ await this.setDescriptor(folderId, descriptor);
6860
+ }
6861
+ /** Writes the descriptor, creating or replacing `document.json`. */
6862
+ async setDescriptor(folderId, descriptor) {
6863
+ const value2 = {
6864
+ ...descriptor,
6865
+ schema: DOCUMENT_SCHEMA,
6866
+ views: descriptor.views ?? []
6867
+ };
6868
+ const blob = new Blob([JSON.stringify(value2)], {
6869
+ type: "application/json"
6870
+ });
6871
+ const existing = await this.#descriptorFile(folderId);
6872
+ if (existing) {
6873
+ await this.client.updateFile(existing.id, {
6874
+ file: blob,
6875
+ versionTag: versionTag$1()
6876
+ });
6877
+ return;
6878
+ }
6879
+ await this.client.createFile({
6880
+ file: blob,
6881
+ name: DOCUMENT_FILE,
6882
+ versionTag: versionTag$1(),
6883
+ parentFolderId: folderId,
6884
+ projectId: this.projectId
6885
+ });
6886
+ }
6887
+ async #descriptorFile(folderId) {
6888
+ const items2 = await this.client.listFiles({ folderId });
6889
+ const found = items2.find(
6890
+ (item) => String(item.name ?? "") === DOCUMENT_FILE
6891
+ );
6892
+ return found ? { id: idOf$1(found) } : void 0;
6893
+ }
6894
+ }
6895
+ function normalize$1(parsed) {
6896
+ if (!parsed || typeof parsed !== "object") return null;
6897
+ const raw = parsed;
6898
+ if (raw.schema !== DOCUMENT_SCHEMA) return null;
6899
+ if (typeof raw.name !== "string") return null;
6900
+ const views = Array.isArray(raw.views) ? raw.views.filter(
6901
+ (v) => !!v && typeof v === "object" && typeof v.fileId === "string" && typeof v.format === "string"
6902
+ ) : [];
6903
+ const native = raw.native;
6904
+ const hasNative = !!native && typeof native === "object" && typeof native.fileId === "string" && typeof native.format === "string";
6905
+ return {
6906
+ schema: DOCUMENT_SCHEMA,
6907
+ name: raw.name,
6908
+ ...hasNative ? { native: { fileId: native.fileId, format: native.format } } : {},
6909
+ views
6910
+ };
6911
+ }
6798
6912
  class StaleHistoryError extends Error {
6799
6913
  constructor(expectedGuid, actualGuid) {
6800
6914
  super(
@@ -6811,6 +6925,7 @@ const OPS_FILE = "revitflow_ops.json";
6811
6925
  const OPS_SCHEMA = "revitflow/ops@1";
6812
6926
  const LEGACY_MODEL_FILE = "revitflow_model.frag";
6813
6927
  const INTERNAL_PREFIX = "revitflow_";
6928
+ const LOCKS_ANCHOR = "bimterop-locks-anchor";
6814
6929
  const versionTag = () => `v-${Date.now()}`;
6815
6930
  function childrenOf(folders, parentId) {
6816
6931
  return folders.filter((f) => String(f.parentId ?? "") === parentId);
@@ -6837,10 +6952,22 @@ class ProjectHistory {
6837
6952
  const name = String(folder.name ?? "");
6838
6953
  const dash = name.indexOf("-");
6839
6954
  if (dash <= 0) continue;
6955
+ const files = await this.client.listFiles({
6956
+ folderId: idOf(folder)
6957
+ });
6958
+ const names2 = files.map(
6959
+ (f) => String(f.name ?? "")
6960
+ );
6961
+ const model = names2.find((n) => n === LEGACY_MODEL_FILE) ?? names2.find(
6962
+ (n) => n.endsWith(".frag") && !n.startsWith(INTERNAL_PREFIX)
6963
+ );
6964
+ const hasAny = names2.includes(HISTORY_FILE) || names2.includes(OPS_FILE) || !!model;
6840
6965
  docs.push({
6841
6966
  source: name.slice(0, dash),
6842
6967
  docId: name.slice(dash + 1),
6843
- modelName: void 0
6968
+ folderId: idOf(folder),
6969
+ modelName: model,
6970
+ ...hasAny ? {} : { empty: true }
6844
6971
  });
6845
6972
  }
6846
6973
  return docs;
@@ -6922,6 +7049,105 @@ class FlowDoc {
6922
7049
  const response = await this.client.downloadFile(file.id);
6923
7050
  return await response.blob();
6924
7051
  }
7052
+ /** The document folder's item id — the id the CDE knows this document by. */
7053
+ async folderId() {
7054
+ return await this.#folder(false);
7055
+ }
7056
+ // ─── The document descriptor ───────────────────────────────────────────────
7057
+ /**
7058
+ * The document descriptor (`document.json`), or null when the folder does
7059
+ * not carry one yet — an old folder from before descriptors existed.
7060
+ */
7061
+ async descriptor() {
7062
+ return await this.#documents.descriptor(await this.#folder(false));
7063
+ }
7064
+ /**
7065
+ * The first view whose format appears in `formats`, searched in the order
7066
+ * of the list (the caller's preference wins). Falls back to the visible
7067
+ * model .frag on a descriptor-less folder, so old documents keep viewing.
7068
+ */
7069
+ async view(formats2) {
7070
+ const folderId = await this.#folder(false);
7071
+ const descriptor = await this.#documents.descriptor(folderId);
7072
+ if (descriptor) {
7073
+ for (const format2 of formats2) {
7074
+ const match = descriptor.views.find((v) => v.format === format2);
7075
+ if (match) return match;
7076
+ }
7077
+ return null;
7078
+ }
7079
+ if (!formats2.includes("frag")) return null;
7080
+ const model = await this.#modelFile();
7081
+ return model ? { fileId: model.id, format: "frag" } : null;
7082
+ }
7083
+ /**
7084
+ * Registers a renderable representation, creating the descriptor when the
7085
+ * folder predates them (named after the model file, or the folder itself).
7086
+ * One view per format: a same-format view is replaced, not accumulated.
7087
+ */
7088
+ async addView(view) {
7089
+ const folderId = await this.#folder(true);
7090
+ const descriptor = await this.#documents.descriptor(folderId);
7091
+ if (descriptor) {
7092
+ await this.#documents.addView(folderId, view);
7093
+ return;
7094
+ }
7095
+ const model = await this.#modelFile();
7096
+ await this.#documents.setDescriptor(folderId, {
7097
+ name: model?.name.replace(/\.frag$/, "") ?? this.#folderName,
7098
+ views: [view]
7099
+ });
7100
+ }
7101
+ // ─── Ownership (element locks) ─────────────────────────────────────────────
7102
+ /**
7103
+ * Who currently holds which elements, read from the lock registry the
7104
+ * native plugins arbitrate through. Read-only: claiming and releasing stay
7105
+ * with the plugins. Empty when nobody holds anything (or the document has
7106
+ * no lock anchor yet). Arbitration is applied: a key contested by two
7107
+ * claims counts only for the earliest one, matching the plugins' rule.
7108
+ */
7109
+ async ownership() {
7110
+ const anchor = await this.#file(LOCKS_ANCHOR);
7111
+ if (!anchor) return [];
7112
+ const metas = await this.client.getHiddenFilesByParent(anchor.id);
7113
+ const claims = await Promise.all(
7114
+ metas.map(async (meta) => {
7115
+ try {
7116
+ const id2 = idOf(meta);
7117
+ const response = await this.client.downloadHiddenFile(id2);
7118
+ const parsed = JSON.parse(await response.text());
7119
+ if (!parsed || typeof parsed !== "object") return null;
7120
+ if (!("revitflowlock" in parsed)) return null;
7121
+ if (!Array.isArray(parsed.keys)) return null;
7122
+ return {
7123
+ owner: String(parsed.user ?? ""),
7124
+ keys: parsed.keys.filter((k) => typeof k === "string")
7125
+ };
7126
+ } catch {
7127
+ return null;
7128
+ }
7129
+ })
7130
+ );
7131
+ const taken = /* @__PURE__ */ new Set();
7132
+ const byOwner = /* @__PURE__ */ new Map();
7133
+ for (const claim of claims) {
7134
+ if (!claim) continue;
7135
+ for (const key of claim.keys) {
7136
+ if (!key || taken.has(key)) continue;
7137
+ taken.add(key);
7138
+ const list = byOwner.get(claim.owner) ?? [];
7139
+ list.push(key);
7140
+ byOwner.set(claim.owner, list);
7141
+ }
7142
+ }
7143
+ return [...byOwner.entries()].map(([owner, elements]) => ({
7144
+ owner,
7145
+ elements
7146
+ }));
7147
+ }
7148
+ get #documents() {
7149
+ return new ProjectDocuments(this.client, this.project.projectId);
7150
+ }
6925
7151
  // ─── Writing versions ─────────────────────────────────────────────────────
6926
7152
  /**
6927
7153
  * Appends one version: uploads the geometry it carries, then the updated
@@ -7003,6 +7229,7 @@ class FlowDoc {
7003
7229
  const indexFile = await this.#file(OPS_FILE);
7004
7230
  const suggestion = {
7005
7231
  opId: input.opId ?? mintGuid(),
7232
+ to: input.to ?? this.source,
7006
7233
  title: input.title,
7007
7234
  verb: input.verb,
7008
7235
  author: input.author,
@@ -7260,14 +7487,39 @@ class Recipes {
7260
7487
  return this.#registry.get(name);
7261
7488
  }
7262
7489
  /**
7263
- * Runs a recipe WITHOUT queueing anything: the dry pass. Returns the
7264
- * recipe's result — title, tallies per rule, targets — so an app can show
7265
- * what would be proposed before anybody confirms. The payload it may have
7266
- * built is discarded along with its opId.
7490
+ * Runs a recipe WITHOUT queueing anything: the dry pass. Returns a
7491
+ * {@link RecipePlan} — the result to show (title, tallies, targets) plus
7492
+ * everything {@link queue} needs to queue EXACTLY this, payload and opId
7493
+ * included, with no second run. Discard it and the plan never happened.
7267
7494
  */
7268
7495
  async plan(name, options) {
7269
- const { result } = await this.#execute(name, options);
7270
- return result;
7496
+ const { base, opId, result } = await this.#execute(name, options);
7497
+ return {
7498
+ opId,
7499
+ projectId: options.projectId,
7500
+ target: options.target,
7501
+ ...base?.guid ? { baseCommitGuid: base.guid } : {},
7502
+ result
7503
+ };
7504
+ }
7505
+ /**
7506
+ * Queues exactly what {@link plan} planned: same opId, same payload, no
7507
+ * second execution. The pair plan → confirm → queue is the UI flow; `run`
7508
+ * is the one-shot shortcut.
7509
+ */
7510
+ async queue(plan, options) {
7511
+ const targetDoc = this.client.history(plan.projectId).doc(plan.target.source, plan.target.docId);
7512
+ const suggestion = await targetDoc.suggest({
7513
+ opId: plan.opId,
7514
+ title: plan.result.title,
7515
+ verb: plan.result.verb,
7516
+ author: options.author,
7517
+ payload: plan.result.payload,
7518
+ targets: plan.result.targets,
7519
+ userId: options.userId,
7520
+ baseCommitGuid: plan.baseCommitGuid
7521
+ });
7522
+ return { suggestion, result: plan.result };
7271
7523
  }
7272
7524
  /**
7273
7525
  * Runs a recipe and queues its suggestion on the target document. Safe to
@@ -8320,7 +8572,15 @@ return main;`);
8320
8572
  console.log(e);
8321
8573
  });
8322
8574
  }
8323
- // ─── History & Recipes ───────────────────────────────────────────
8575
+ // ─── Documents, History & Recipes ────────────────────────────────
8576
+ /**
8577
+ * A project's documents. Every document is a FOLDER holding its files plus
8578
+ * a `document.json` descriptor naming the document, its native form and its
8579
+ * renderable views. See `src/core/documents.ts` for the model and schema.
8580
+ */
8581
+ documents(projectId) {
8582
+ return new ProjectDocuments(this, projectId);
8583
+ }
8324
8584
  /**
8325
8585
  * A project's history of connected documents (Revit models, AutoCAD
8326
8586
  * drawings…): their version timelines and their change requests. See