@contentrain/wp-import 0.6.0 → 0.8.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/README.md +102 -0
- package/dist/index.d.mts +18 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1061 -33
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -675,6 +675,734 @@ function guessMime(u) {
|
|
|
675
675
|
return MIME[(u.split("?")[0]?.split(".").pop() ?? "").toLowerCase()] ?? null;
|
|
676
676
|
}
|
|
677
677
|
//#endregion
|
|
678
|
+
//#region src/acf.ts
|
|
679
|
+
/** ACF types that never carry content (layout) or must never be read (secrets). */
|
|
680
|
+
const ACF_LAYOUT_TYPES = new Set([
|
|
681
|
+
"tab",
|
|
682
|
+
"accordion",
|
|
683
|
+
"message"
|
|
684
|
+
]);
|
|
685
|
+
const ACF_SECRET_TYPES = new Set(["password"]);
|
|
686
|
+
/**
|
|
687
|
+
* ACF type → Contentrain type, for types whose value maps without a lookup.
|
|
688
|
+
* `select` with `multiple` and `checkbox` are arrays of their options; see `acfFieldDef`.
|
|
689
|
+
*/
|
|
690
|
+
const ACF_SCALAR_TYPES = {
|
|
691
|
+
text: "string",
|
|
692
|
+
textarea: "text",
|
|
693
|
+
wysiwyg: "richtext",
|
|
694
|
+
email: "email",
|
|
695
|
+
url: "url",
|
|
696
|
+
oembed: "url",
|
|
697
|
+
number: "number",
|
|
698
|
+
range: "number",
|
|
699
|
+
true_false: "boolean",
|
|
700
|
+
date_picker: "date",
|
|
701
|
+
date_time_picker: "datetime",
|
|
702
|
+
time_picker: "string",
|
|
703
|
+
color_picker: "color",
|
|
704
|
+
icon_picker: "icon",
|
|
705
|
+
select: "select",
|
|
706
|
+
radio: "select",
|
|
707
|
+
button_group: "select",
|
|
708
|
+
image: "image",
|
|
709
|
+
file: "file"
|
|
710
|
+
};
|
|
711
|
+
const ACF_REFERENCE_TYPES = {
|
|
712
|
+
post_object: "post",
|
|
713
|
+
relationship: "post",
|
|
714
|
+
taxonomy: "term",
|
|
715
|
+
user: "user",
|
|
716
|
+
gallery: "media",
|
|
717
|
+
page_link: "address"
|
|
718
|
+
};
|
|
719
|
+
/**
|
|
720
|
+
* Names that mean a secret whatever the field's type, for sources that do not state the type: whole words
|
|
721
|
+
* of the name (`user_pass`, `apiKey`, `client-secret`), so `passage_text` or `compass` stay content.
|
|
722
|
+
*/
|
|
723
|
+
const SECRET_NAME = /(?:^|_)(?:pass(?:word|wd)?|secret|token|api_?key|private_?key|credentials?)(?:_|$)/i;
|
|
724
|
+
const secretName = (name) => SECRET_NAME.test(name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/-/g, "_"));
|
|
725
|
+
/** Whether a field must never be read: a secret by type, or — when the type is unknown — by name. */
|
|
726
|
+
function acfIsSecret(name, type) {
|
|
727
|
+
if (type) return ACF_SECRET_TYPES.has(type);
|
|
728
|
+
return secretName(name);
|
|
729
|
+
}
|
|
730
|
+
const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
731
|
+
const SOURCE = "_source";
|
|
732
|
+
/** The ACF type SCF states for a sub-field of a row or group (`<name>_source.type`). */
|
|
733
|
+
const statedType = (row, k) => {
|
|
734
|
+
const s = row[`${k}${SOURCE}`];
|
|
735
|
+
return isRecord(s) && typeof s.type === "string" ? s.type : void 0;
|
|
736
|
+
};
|
|
737
|
+
/** A sub-field that carries nothing to keep: a secret (stated, or by name) or a layout field. */
|
|
738
|
+
const droppedKey = (row, k) => {
|
|
739
|
+
const t = statedType(row, k);
|
|
740
|
+
return t !== void 0 && ACF_LAYOUT_TYPES.has(t) || acfIsSecret(k, t);
|
|
741
|
+
};
|
|
742
|
+
/**
|
|
743
|
+
* An ACF value with every secret sub-field removed, at any depth: repeater rows, groups, flexible layouts.
|
|
744
|
+
* A sub-field's `_source` keeps only its type and label — its `formatted_value` repeats the value.
|
|
745
|
+
*/
|
|
746
|
+
function acfScrub(value, secret = /* @__PURE__ */ new Set()) {
|
|
747
|
+
if (Array.isArray(value)) {
|
|
748
|
+
const rows = value.filter(isRecord);
|
|
749
|
+
const shared = new Set([...secret, ...rows.flatMap((r) => Object.keys(r).filter((k) => !k.endsWith(SOURCE) && droppedKey(r, k)))]);
|
|
750
|
+
return value.map((v) => acfScrub(v, shared));
|
|
751
|
+
}
|
|
752
|
+
if (!isRecord(value)) return value;
|
|
753
|
+
const gone = (k) => secret.has(k) || droppedKey(value, k);
|
|
754
|
+
const out = {};
|
|
755
|
+
for (const [k, v] of Object.entries(value)) {
|
|
756
|
+
if (k.endsWith(SOURCE) && k.length > 7) {
|
|
757
|
+
if (gone(k.slice(0, -7)) || !isRecord(v)) continue;
|
|
758
|
+
out[k] = {
|
|
759
|
+
...typeof v.type === "string" ? { type: v.type } : {},
|
|
760
|
+
...typeof v.label === "string" ? { label: v.label } : {}
|
|
761
|
+
};
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (gone(k)) continue;
|
|
765
|
+
out[k] = acfScrub(v);
|
|
766
|
+
}
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
/** ACF's image/file array (`return_format: array`): `{ ID, url, filename, … }`. */
|
|
770
|
+
const isAttachment = (v) => isRecord(v) && typeof v.url === "string" && ("ID" in v || "id" in v) && ("filename" in v || "mime_type" in v);
|
|
771
|
+
const isLink = (v) => isRecord(v) && typeof v.url === "string" && "title" in v && "target" in v && Object.keys(v).length <= 3;
|
|
772
|
+
const isMap = (v) => isRecord(v) && typeof v.lat === "number" && typeof v.lng === "number";
|
|
773
|
+
/** A date picker's stored value (`Ymd`) → ISO date. */
|
|
774
|
+
const ymd = (v) => typeof v === "string" && /^\d{8}$/.test(v) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : null;
|
|
775
|
+
/** `Y-m-d H:i:s` → ISO local datetime (site time: WordPress stores it without a zone). */
|
|
776
|
+
const dt = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v) ? v.replace(" ", "T") : null;
|
|
777
|
+
const pad = (n) => String(Math.floor(n)).padStart(2, "0");
|
|
778
|
+
/** Minutes east of UTC → `+03:00`. */
|
|
779
|
+
const offsetText = (minutes) => `${minutes < 0 ? "-" : "+"}${pad(Math.abs(minutes) / 60)}:${pad(Math.abs(minutes) % 60)}`;
|
|
780
|
+
/** The offset of an IANA zone at a wall-clock time there (DST included), in minutes east of UTC; `null` for an unknown zone. */
|
|
781
|
+
function zoneOffset(local, timeZone) {
|
|
782
|
+
let fmt;
|
|
783
|
+
try {
|
|
784
|
+
fmt = new Intl.DateTimeFormat("en-US", {
|
|
785
|
+
timeZone,
|
|
786
|
+
hourCycle: "h23",
|
|
787
|
+
year: "numeric",
|
|
788
|
+
month: "2-digit",
|
|
789
|
+
day: "2-digit",
|
|
790
|
+
hour: "2-digit",
|
|
791
|
+
minute: "2-digit",
|
|
792
|
+
second: "2-digit"
|
|
793
|
+
});
|
|
794
|
+
} catch {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
const wall = Date.parse(`${local}Z`);
|
|
798
|
+
const at = (utc) => {
|
|
799
|
+
const p = Object.fromEntries(fmt.formatToParts(new Date(utc)).map((x) => [x.type, x.value]));
|
|
800
|
+
return (Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second)) - utc) / 6e4;
|
|
801
|
+
};
|
|
802
|
+
return at(wall - at(wall) * 6e4);
|
|
803
|
+
}
|
|
804
|
+
/** A local ISO datetime with the site's offset for that moment; the local value when the site's zone is unknown. */
|
|
805
|
+
function zonedDateTime(local, zone = {}) {
|
|
806
|
+
const minutes = (zone.timeZone ? zoneOffset(local, zone.timeZone) : null) ?? (typeof zone.gmtOffset === "number" && Number.isFinite(zone.gmtOffset) ? Math.round(zone.gmtOffset * 60) : null);
|
|
807
|
+
return minutes === null ? {
|
|
808
|
+
value: local,
|
|
809
|
+
zoned: false
|
|
810
|
+
} : {
|
|
811
|
+
value: `${local}${offsetText(minutes)}`,
|
|
812
|
+
zoned: true
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const LINK_FIELDS = {
|
|
816
|
+
url: { type: "url" },
|
|
817
|
+
title: { type: "string" },
|
|
818
|
+
target: { type: "string" }
|
|
819
|
+
};
|
|
820
|
+
const MAP_FIELDS = {
|
|
821
|
+
address: { type: "string" },
|
|
822
|
+
lat: { type: "decimal" },
|
|
823
|
+
lng: { type: "decimal" },
|
|
824
|
+
zoom: { type: "integer" }
|
|
825
|
+
};
|
|
826
|
+
/** Contentrain type of a sub-value whose ACF type is not stated (a repeater row, a group member). */
|
|
827
|
+
function inferDef(value) {
|
|
828
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
829
|
+
if (isAttachment(value)) return { type: /\.(jpe?g|png|gif|webp|avif|svg)(\?|$)/i.test(value.url) ? "image" : "file" };
|
|
830
|
+
if (isLink(value)) return {
|
|
831
|
+
type: "object",
|
|
832
|
+
fields: LINK_FIELDS
|
|
833
|
+
};
|
|
834
|
+
if (isMap(value)) return {
|
|
835
|
+
type: "object",
|
|
836
|
+
fields: MAP_FIELDS
|
|
837
|
+
};
|
|
838
|
+
if (Array.isArray(value)) {
|
|
839
|
+
if (value.every(isRecord)) return {
|
|
840
|
+
type: "array",
|
|
841
|
+
items: {
|
|
842
|
+
type: "object",
|
|
843
|
+
fields: rowFields(value)
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
const item = value.map(inferDef).find(Boolean);
|
|
847
|
+
return {
|
|
848
|
+
type: "array",
|
|
849
|
+
items: item?.type === "object" ? item : item?.type ?? "string"
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
if (isRecord(value)) return {
|
|
853
|
+
type: "object",
|
|
854
|
+
fields: rowFields([value])
|
|
855
|
+
};
|
|
856
|
+
if (ymd(value)) return { type: "date" };
|
|
857
|
+
const t = byValue(value);
|
|
858
|
+
return t ? { type: t } : null;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* The union of the fields every row states; a name typed two ways falls back to `string`. A sub-field whose ACF
|
|
862
|
+
* type the row states (SCF's `<name>_source`) is typed by the table like a top-level field; a reference inside a
|
|
863
|
+
* row is not resolved, so it — and any sub-field without a stated type — is typed from its value.
|
|
864
|
+
*/
|
|
865
|
+
function rowFields(rows) {
|
|
866
|
+
const out = {};
|
|
867
|
+
for (const row of rows) for (const [k, v] of Object.entries(row)) {
|
|
868
|
+
if (k.endsWith(SOURCE) || droppedKey(row, k)) continue;
|
|
869
|
+
const stated = statedType(row, k);
|
|
870
|
+
const source = row[`${k}${SOURCE}`];
|
|
871
|
+
const label = isRecord(source) && typeof source.label === "string" ? source.label : void 0;
|
|
872
|
+
const d = stated && !ACF_REFERENCE_TYPES[stated] ? acfFieldDef(k, v, {
|
|
873
|
+
type: stated,
|
|
874
|
+
...label ? { label } : {}
|
|
875
|
+
}) : inferDef(v);
|
|
876
|
+
if (!d) continue;
|
|
877
|
+
const seen = out[k];
|
|
878
|
+
if (!seen) out[k] = d;
|
|
879
|
+
else if (seen.type !== d.type) out[k] = { type: "string" };
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
/** A value in the shape its field definition promises; `undefined` = nothing to store. */
|
|
884
|
+
function acfValue(def, value, ctx = {}) {
|
|
885
|
+
if (value === null || value === void 0 || value === "" || Array.isArray(value) && !value.length) return void 0;
|
|
886
|
+
switch (def.type) {
|
|
887
|
+
case "image":
|
|
888
|
+
case "file":
|
|
889
|
+
case "video": return isAttachment(value) ? value.url : typeof value === "string" ? value : void 0;
|
|
890
|
+
case "date": return ymd(value) ?? (typeof value === "string" ? value : void 0);
|
|
891
|
+
case "datetime": {
|
|
892
|
+
const local = dt(value);
|
|
893
|
+
if (!local) return typeof value === "string" ? value : void 0;
|
|
894
|
+
const out = zonedDateTime(local, ctx);
|
|
895
|
+
if (!out.zoned) ctx.unzoned?.(out.value);
|
|
896
|
+
return out.value;
|
|
897
|
+
}
|
|
898
|
+
case "boolean": return value === true || value === 1 || value === "1";
|
|
899
|
+
case "number":
|
|
900
|
+
case "integer":
|
|
901
|
+
case "decimal": return typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value)) ? Number(value) : void 0;
|
|
902
|
+
case "icon": return isRecord(value) && typeof value.value === "string" ? value.value : typeof value === "string" ? value : void 0;
|
|
903
|
+
case "object": {
|
|
904
|
+
if (!isRecord(value)) return void 0;
|
|
905
|
+
const out = {};
|
|
906
|
+
for (const [k, sub] of Object.entries(def.fields ?? {})) {
|
|
907
|
+
if (droppedKey(value, k)) continue;
|
|
908
|
+
const v = acfValue(sub, value[k], ctx);
|
|
909
|
+
if (v !== void 0) out[k] = v;
|
|
910
|
+
}
|
|
911
|
+
return Object.keys(out).length ? out : void 0;
|
|
912
|
+
}
|
|
913
|
+
case "array": {
|
|
914
|
+
if (!Array.isArray(value)) return void 0;
|
|
915
|
+
const item = typeof def.items === "string" ? { type: def.items } : def.items ?? { type: "string" };
|
|
916
|
+
const out = value.map((v) => acfValue(item, v, ctx)).filter((v) => v !== void 0);
|
|
917
|
+
return out.length ? out : void 0;
|
|
918
|
+
}
|
|
919
|
+
case "select": {
|
|
920
|
+
const v = typeof value === "string" ? value : typeof value === "number" ? String(value) : void 0;
|
|
921
|
+
if (v !== void 0 && def.options?.length && !def.options.includes(v)) {
|
|
922
|
+
ctx.dropped?.(v, def.options);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
return v;
|
|
926
|
+
}
|
|
927
|
+
case "richtext":
|
|
928
|
+
case "text":
|
|
929
|
+
case "string":
|
|
930
|
+
case "url":
|
|
931
|
+
case "email":
|
|
932
|
+
case "color": return typeof value === "string" ? value : typeof value === "number" ? String(value) : void 0;
|
|
933
|
+
default: return value;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Field definition for one ACF field value, or `null` when it carries no content, is a secret, or is a
|
|
938
|
+
* reference (the caller resolves those: see `ACF_REFERENCE_TYPES`).
|
|
939
|
+
*/
|
|
940
|
+
function acfFieldDef(name, value, stated = {}) {
|
|
941
|
+
const t = stated.type;
|
|
942
|
+
if (t && (ACF_LAYOUT_TYPES.has(t) || ACF_REFERENCE_TYPES[t])) return null;
|
|
943
|
+
if (acfIsSecret(name, t)) return null;
|
|
944
|
+
const label = stated.label ? strip(stated.label) : void 0;
|
|
945
|
+
const withLabel = (d) => label ? {
|
|
946
|
+
...d,
|
|
947
|
+
label
|
|
948
|
+
} : d;
|
|
949
|
+
if (!t) {
|
|
950
|
+
const inferred = inferDef(value);
|
|
951
|
+
return inferred ? withLabel({
|
|
952
|
+
...inferred,
|
|
953
|
+
description: "ACF (type inferred from the value)"
|
|
954
|
+
}) : null;
|
|
955
|
+
}
|
|
956
|
+
const choices = stated.choices?.length ? stated.choices : void 0;
|
|
957
|
+
if (t === "checkbox" || t === "select" && (stated.multiple || Array.isArray(value))) return withLabel({
|
|
958
|
+
type: "array",
|
|
959
|
+
items: choices ? {
|
|
960
|
+
type: "select",
|
|
961
|
+
options: choices
|
|
962
|
+
} : "string"
|
|
963
|
+
});
|
|
964
|
+
if (ACF_SCALAR_TYPES[t]) {
|
|
965
|
+
const type = ACF_SCALAR_TYPES[t];
|
|
966
|
+
if (type === "select") return withLabel(choices ? {
|
|
967
|
+
type,
|
|
968
|
+
options: choices
|
|
969
|
+
} : { type: "string" });
|
|
970
|
+
return withLabel({ type });
|
|
971
|
+
}
|
|
972
|
+
if (t === "link") return withLabel({
|
|
973
|
+
type: "object",
|
|
974
|
+
fields: LINK_FIELDS
|
|
975
|
+
});
|
|
976
|
+
if (t === "google_map") return withLabel({
|
|
977
|
+
type: "object",
|
|
978
|
+
fields: MAP_FIELDS
|
|
979
|
+
});
|
|
980
|
+
if (t === "group") return withLabel({
|
|
981
|
+
type: "object",
|
|
982
|
+
fields: rowFields(isRecord(value) ? [value] : [])
|
|
983
|
+
});
|
|
984
|
+
if (t === "repeater") return withLabel({
|
|
985
|
+
type: "array",
|
|
986
|
+
items: {
|
|
987
|
+
type: "object",
|
|
988
|
+
fields: rowFields(Array.isArray(value) ? value.filter(isRecord) : [])
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
if (t === "flexible_content") {
|
|
992
|
+
const rows = Array.isArray(value) ? value.filter(isRecord) : [];
|
|
993
|
+
const fields = rowFields(rows.map(({ acf_fc_layout: _l, ...rest }) => rest));
|
|
994
|
+
return withLabel({
|
|
995
|
+
type: "array",
|
|
996
|
+
items: {
|
|
997
|
+
type: "object",
|
|
998
|
+
fields: {
|
|
999
|
+
layout: {
|
|
1000
|
+
type: "select",
|
|
1001
|
+
options: [...new Set(rows.map((r) => String(r.acf_fc_layout ?? "")).filter(Boolean))].toSorted(),
|
|
1002
|
+
required: true
|
|
1003
|
+
},
|
|
1004
|
+
...fields
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
const inferred = inferDef(value);
|
|
1010
|
+
return inferred ? withLabel({
|
|
1011
|
+
...inferred,
|
|
1012
|
+
description: `ACF ${t} (type inferred from the value)`
|
|
1013
|
+
}) : null;
|
|
1014
|
+
}
|
|
1015
|
+
/** Merge two definitions of the same field seen on different entries (union of object fields and options). */
|
|
1016
|
+
function mergeFieldDef(a, b) {
|
|
1017
|
+
if (a.type !== b.type) return a;
|
|
1018
|
+
const out = { ...a };
|
|
1019
|
+
if (a.options || b.options) out.options = [...new Set([...a.options ?? [], ...b.options ?? []])].toSorted();
|
|
1020
|
+
if (a.fields || b.fields) {
|
|
1021
|
+
const fields = { ...a.fields };
|
|
1022
|
+
for (const [k, d] of Object.entries(b.fields ?? {})) fields[k] = fields[k] ? mergeFieldDef(fields[k], d) : d;
|
|
1023
|
+
out.fields = fields;
|
|
1024
|
+
}
|
|
1025
|
+
if (typeof a.items === "object" && typeof b.items === "object") out.items = mergeFieldDef(a.items, b.items);
|
|
1026
|
+
return out;
|
|
1027
|
+
}
|
|
1028
|
+
/** Flexible content rows keep their layout name under `layout`. */
|
|
1029
|
+
function acfRows(value) {
|
|
1030
|
+
return Array.isArray(value) ? value.map((r) => isRecord(r) && "acf_fc_layout" in r ? (({ acf_fc_layout, ...rest }) => ({
|
|
1031
|
+
layout: acf_fc_layout,
|
|
1032
|
+
...rest
|
|
1033
|
+
}))(r) : r) : value;
|
|
1034
|
+
}
|
|
1035
|
+
//#endregion
|
|
1036
|
+
//#region src/rest-menus.ts
|
|
1037
|
+
const targetOf = (kind, object, id, url, ctx) => {
|
|
1038
|
+
const objectId = id && id > 0 ? id : null;
|
|
1039
|
+
if (kind === "post_type" && object) {
|
|
1040
|
+
const slug = objectId ? ctx.postSlug(objectId) : void 0;
|
|
1041
|
+
return {
|
|
1042
|
+
kind: "post",
|
|
1043
|
+
post_type: object,
|
|
1044
|
+
id: objectId,
|
|
1045
|
+
slug: slug ?? null,
|
|
1046
|
+
resolved: slug !== void 0
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
if (kind === "taxonomy" && object) {
|
|
1050
|
+
const slug = objectId ? ctx.termSlug(object, objectId) : void 0;
|
|
1051
|
+
return {
|
|
1052
|
+
kind: "term",
|
|
1053
|
+
taxonomy: object,
|
|
1054
|
+
id: objectId,
|
|
1055
|
+
slug: slug ?? null,
|
|
1056
|
+
resolved: slug !== void 0
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
if (kind === "post_type_archive" && object) return {
|
|
1060
|
+
kind: "archive",
|
|
1061
|
+
post_type: object,
|
|
1062
|
+
resolved: true
|
|
1063
|
+
};
|
|
1064
|
+
if (url) return {
|
|
1065
|
+
kind: "url",
|
|
1066
|
+
url,
|
|
1067
|
+
resolved: true
|
|
1068
|
+
};
|
|
1069
|
+
return {
|
|
1070
|
+
kind: "unknown",
|
|
1071
|
+
resolved: false
|
|
1072
|
+
};
|
|
1073
|
+
};
|
|
1074
|
+
/**
|
|
1075
|
+
* Items that are left out: a draft item, or one whose post target is not proven visible. Their children move up
|
|
1076
|
+
* to the nearest kept ancestor, as WordPress's walker shows them.
|
|
1077
|
+
*/
|
|
1078
|
+
function hiddenItems(items, drop) {
|
|
1079
|
+
const parentOf = new Map(items.filter(drop).map((i) => [i.id, i.parent || null]));
|
|
1080
|
+
const lift = (parent) => {
|
|
1081
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1082
|
+
let p = parent;
|
|
1083
|
+
while (p !== null && parentOf.has(p) && !seen.has(p)) {
|
|
1084
|
+
seen.add(p);
|
|
1085
|
+
p = parentOf.get(p) ?? null;
|
|
1086
|
+
}
|
|
1087
|
+
return p;
|
|
1088
|
+
};
|
|
1089
|
+
return {
|
|
1090
|
+
gone: new Set(parentOf.keys()),
|
|
1091
|
+
lift
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* The post a plain permalink names (`/?page_id=14`, `/?p=14`) on this site: a custom link typed by hand carries no
|
|
1096
|
+
* post id, but it points at a post all the same, and its label is often that post's title.
|
|
1097
|
+
*/
|
|
1098
|
+
function plainPostId(url, origin) {
|
|
1099
|
+
if (!url || !url.includes("?")) return void 0;
|
|
1100
|
+
try {
|
|
1101
|
+
const u = new URL(url, `${origin}/`);
|
|
1102
|
+
if (u.origin !== new URL(origin).origin) return void 0;
|
|
1103
|
+
const id = Number(u.searchParams.get("page_id") ?? u.searchParams.get("p"));
|
|
1104
|
+
return Number.isSafeInteger(id) && id > 0 ? id : void 0;
|
|
1105
|
+
} catch {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/** Classic menus and their items; each menu names the theme locations it is assigned to. `dropped` counts left-out items. */
|
|
1110
|
+
function classicMenus(menus, items, ctx, dropped = { count: 0 }) {
|
|
1111
|
+
const out = [];
|
|
1112
|
+
for (const m of [...menus].toSorted((a, b) => a.id - b.id)) {
|
|
1113
|
+
const all = items.filter((i) => i.menus === m.id).toSorted((a, b) => (a.menu_order ?? 0) - (b.menu_order ?? 0) || a.id - b.id);
|
|
1114
|
+
const hidden = (i) => {
|
|
1115
|
+
if ((i.status ?? "publish") !== "publish") return true;
|
|
1116
|
+
if (i.type === "post_type") return !(i.object_id && ctx.postSlug(i.object_id));
|
|
1117
|
+
const id = plainPostId(i.url, ctx.origin);
|
|
1118
|
+
return id !== void 0 && !ctx.postSlug(id);
|
|
1119
|
+
};
|
|
1120
|
+
const { gone, lift } = hiddenItems(all, hidden);
|
|
1121
|
+
dropped.count += gone.size;
|
|
1122
|
+
const own = all.filter((i) => !gone.has(i.id));
|
|
1123
|
+
const ids = new Set(own.map((i) => i.id));
|
|
1124
|
+
const menu = {
|
|
1125
|
+
id: m.id,
|
|
1126
|
+
slug: m.slug ?? "",
|
|
1127
|
+
name: strip(m.name ?? m.slug ?? ""),
|
|
1128
|
+
items: own.map((i) => {
|
|
1129
|
+
const item = {
|
|
1130
|
+
id: i.id,
|
|
1131
|
+
title: i.title?.raw || i.title?.rendered || "",
|
|
1132
|
+
order: i.menu_order ?? 0,
|
|
1133
|
+
parent: lift(i.parent || null),
|
|
1134
|
+
url: i.url || null,
|
|
1135
|
+
target: targetOf(i.type, i.object, i.object_id, i.url, ctx),
|
|
1136
|
+
target_attr: i.target || null,
|
|
1137
|
+
classes: (i.classes ?? []).filter(Boolean),
|
|
1138
|
+
description: i.description ?? "",
|
|
1139
|
+
status: i.status ?? "publish"
|
|
1140
|
+
};
|
|
1141
|
+
if (item.parent && !ids.has(item.parent)) item.parent_unresolved = true;
|
|
1142
|
+
return item;
|
|
1143
|
+
})
|
|
1144
|
+
};
|
|
1145
|
+
const locations = (m.locations ?? []).filter(Boolean).toSorted();
|
|
1146
|
+
if (locations.length) menu.locations = locations;
|
|
1147
|
+
out.push(menu);
|
|
1148
|
+
}
|
|
1149
|
+
return out;
|
|
1150
|
+
}
|
|
1151
|
+
/** Serialized block comments (`<!-- wp:name {attrs} /-->`, `<!-- /wp:name -->`). Same grammar as the facts builder parser. */
|
|
1152
|
+
const BLOCK_TOKEN = /<!--\s+(\/)?wp:([a-z][a-z0-9_-]*\/)?([a-z][a-z0-9_-]*)\s+({(?:(?!}\s+\/?-->)[\s\S])*?}\s+)?(\/)?-->/g;
|
|
1153
|
+
function parseBlocks(content) {
|
|
1154
|
+
const root = {
|
|
1155
|
+
name: "root",
|
|
1156
|
+
attrs: {},
|
|
1157
|
+
children: []
|
|
1158
|
+
};
|
|
1159
|
+
const stack = [root];
|
|
1160
|
+
const token = new RegExp(BLOCK_TOKEN.source, "g");
|
|
1161
|
+
let m;
|
|
1162
|
+
while (m = token.exec(content)) {
|
|
1163
|
+
const [, closer, ns, local, json, selfClose] = m;
|
|
1164
|
+
if (closer) {
|
|
1165
|
+
if (stack.length > 1) stack.pop();
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
let attrs = {};
|
|
1169
|
+
if (json) try {
|
|
1170
|
+
attrs = JSON.parse(json);
|
|
1171
|
+
} catch {
|
|
1172
|
+
attrs = {};
|
|
1173
|
+
}
|
|
1174
|
+
const block = {
|
|
1175
|
+
name: `${ns ? ns.slice(0, -1) : "core"}/${local}`,
|
|
1176
|
+
attrs,
|
|
1177
|
+
children: []
|
|
1178
|
+
};
|
|
1179
|
+
stack[stack.length - 1].children.push(block);
|
|
1180
|
+
if (!selfClose) stack.push(block);
|
|
1181
|
+
}
|
|
1182
|
+
return root.children;
|
|
1183
|
+
}
|
|
1184
|
+
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
1185
|
+
const absolute = (url, origin) => {
|
|
1186
|
+
if (!url) return void 0;
|
|
1187
|
+
if (url.startsWith("#")) return url;
|
|
1188
|
+
try {
|
|
1189
|
+
return new URL(url, `${origin}/`).href;
|
|
1190
|
+
} catch {
|
|
1191
|
+
return url;
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
/** The block editor's link kinds → the classic menu item types. */
|
|
1195
|
+
const KIND = {
|
|
1196
|
+
"post-type": "post_type",
|
|
1197
|
+
taxonomy: "taxonomy",
|
|
1198
|
+
custom: "custom"
|
|
1199
|
+
};
|
|
1200
|
+
/**
|
|
1201
|
+
* Menu items of one `wp_navigation` body. Links and submenus become items; a page list becomes the
|
|
1202
|
+
* published pages it lists; a home link points at `/`. Other blocks (search, social icons, spacers) are not links.
|
|
1203
|
+
*/
|
|
1204
|
+
function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
|
|
1205
|
+
return blockItems(parseBlocks(content), ctx, nextId, dropped);
|
|
1206
|
+
}
|
|
1207
|
+
/** Menu items of navigation blocks already parsed (a `wp_navigation` body, or an inline navigation's links). */
|
|
1208
|
+
function blockItems(tree, ctx, nextId, dropped) {
|
|
1209
|
+
const items = [];
|
|
1210
|
+
const push = (b, parent) => {
|
|
1211
|
+
const id = nextId();
|
|
1212
|
+
const url = absolute(b.url, ctx.origin);
|
|
1213
|
+
items.push({
|
|
1214
|
+
id,
|
|
1215
|
+
title: b.title,
|
|
1216
|
+
order: items.length + 1,
|
|
1217
|
+
parent,
|
|
1218
|
+
url: url ?? null,
|
|
1219
|
+
target: targetOf(b.kind, b.object, b.id, url, ctx),
|
|
1220
|
+
target_attr: b.newTab ? "_blank" : null,
|
|
1221
|
+
classes: (b.className ?? "").split(/\s+/).filter(Boolean),
|
|
1222
|
+
description: b.description ?? "",
|
|
1223
|
+
status: "publish"
|
|
1224
|
+
});
|
|
1225
|
+
return id;
|
|
1226
|
+
};
|
|
1227
|
+
const pageList = (parentPage, parent) => {
|
|
1228
|
+
const kids = ctx.pages.filter((p) => (p.parent ?? 0) === parentPage).toSorted((a, b) => a.menu_order - b.menu_order || a.title.localeCompare(b.title));
|
|
1229
|
+
for (const p of kids) {
|
|
1230
|
+
const id = push({
|
|
1231
|
+
title: p.title,
|
|
1232
|
+
url: p.link ?? void 0,
|
|
1233
|
+
kind: "post_type",
|
|
1234
|
+
object: "page",
|
|
1235
|
+
id: p.id
|
|
1236
|
+
}, parent);
|
|
1237
|
+
pageList(p.id, id);
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
const walk = (blocks, parent) => {
|
|
1241
|
+
for (const b of blocks) {
|
|
1242
|
+
const a = b.attrs;
|
|
1243
|
+
if (b.name === "core/navigation-link" || b.name === "core/navigation-submenu") {
|
|
1244
|
+
const kind = KIND[str(a.kind) ?? ""] ?? (str(a.type) === "category" || str(a.type) === "tag" || str(a.type) === "post_tag" ? "taxonomy" : a.id ? "post_type" : "custom");
|
|
1245
|
+
const object = kind === "taxonomy" && str(a.type) === "tag" ? "post_tag" : str(a.type);
|
|
1246
|
+
const postId = kind === "post_type" && typeof a.id === "number" ? a.id : kind === "custom" ? plainPostId(str(a.url), ctx.origin) : void 0;
|
|
1247
|
+
if (postId !== void 0 && !ctx.postSlug(postId)) {
|
|
1248
|
+
dropped.count++;
|
|
1249
|
+
walk(b.children, parent);
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
const id = push({
|
|
1253
|
+
title: strip(a.label),
|
|
1254
|
+
url: str(a.url),
|
|
1255
|
+
kind,
|
|
1256
|
+
object,
|
|
1257
|
+
id: typeof a.id === "number" ? a.id : void 0,
|
|
1258
|
+
newTab: a.opensInNewTab === true,
|
|
1259
|
+
className: str(a.className),
|
|
1260
|
+
description: str(a.description)
|
|
1261
|
+
}, parent);
|
|
1262
|
+
walk(b.children, id);
|
|
1263
|
+
} else if (b.name === "core/home-link") push({
|
|
1264
|
+
title: strip(a.label) || "Home",
|
|
1265
|
+
url: "/",
|
|
1266
|
+
className: str(a.className)
|
|
1267
|
+
}, parent);
|
|
1268
|
+
else if (b.name === "core/page-list") pageList(typeof a.parentPageID === "number" ? a.parentPageID : 0, parent);
|
|
1269
|
+
else walk(b.children, parent);
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
walk(tree, null);
|
|
1273
|
+
return items;
|
|
1274
|
+
}
|
|
1275
|
+
/** Template-part slugs the templates use, at any depth (a part inside a group, a part inside a part is not followed). */
|
|
1276
|
+
function partSlugs(blocks, out = /* @__PURE__ */ new Set()) {
|
|
1277
|
+
for (const b of blocks) {
|
|
1278
|
+
if (b.name === "core/template-part" && typeof b.attrs.slug === "string") out.add(b.attrs.slug);
|
|
1279
|
+
partSlugs(b.children, out);
|
|
1280
|
+
}
|
|
1281
|
+
return out;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* The template parts a page can show: those a template references. Without the templates (not readable), the
|
|
1285
|
+
* part named after its area (`header`, `footer`) — what a theme's templates use unless they say otherwise.
|
|
1286
|
+
* Parts without a slug (older callers) all count.
|
|
1287
|
+
*/
|
|
1288
|
+
function usedParts(parts, templates = []) {
|
|
1289
|
+
const used = /* @__PURE__ */ new Set();
|
|
1290
|
+
for (const t of templates) if (t.content?.raw) partSlugs(parseBlocks(t.content.raw), used);
|
|
1291
|
+
return parts.filter((p) => !p.slug || (used.size ? used.has(p.slug) : p.slug === p.area));
|
|
1292
|
+
}
|
|
1293
|
+
const AREA_ORDER = ["header", "footer"];
|
|
1294
|
+
const areaOf = (p) => p.area && p.area !== "uncategorized" ? p.area : void 0;
|
|
1295
|
+
/**
|
|
1296
|
+
* A part's blocks with the parts it holds in their place (a header holding a `navigation` part), each part once:
|
|
1297
|
+
* what the part shows, in its area.
|
|
1298
|
+
*/
|
|
1299
|
+
function partTree(part, all) {
|
|
1300
|
+
const bySlug = new Map(all.filter((p) => p.slug).map((p) => [p.slug, p]));
|
|
1301
|
+
const seen = new Set(part.slug ? [part.slug] : []);
|
|
1302
|
+
const expand = (blocks) => blocks.flatMap((b) => {
|
|
1303
|
+
if (b.name === "core/template-part") {
|
|
1304
|
+
const inner = typeof b.attrs.slug === "string" ? bySlug.get(b.attrs.slug) : void 0;
|
|
1305
|
+
if (!inner?.content?.raw || seen.has(inner.slug)) return [];
|
|
1306
|
+
seen.add(inner.slug);
|
|
1307
|
+
return expand(parseBlocks(inner.content.raw));
|
|
1308
|
+
}
|
|
1309
|
+
return [{
|
|
1310
|
+
...b,
|
|
1311
|
+
children: expand(b.children)
|
|
1312
|
+
}];
|
|
1313
|
+
});
|
|
1314
|
+
return expand(parseBlocks(part.content?.raw ?? ""));
|
|
1315
|
+
}
|
|
1316
|
+
const titleCase = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
1317
|
+
const slugOf = (s) => s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1318
|
+
/**
|
|
1319
|
+
* Inline navigations of the used template parts → RawMenu[], in area order (header, footer, others) and document
|
|
1320
|
+
* order within a part. A part's single inline navigation is named after its area (`Footer navigation`), several
|
|
1321
|
+
* are numbered; a navigation's own `ariaLabel` wins. Links keep their order and nesting; a link to content not
|
|
1322
|
+
* proven public is left out, as in every other menu.
|
|
1323
|
+
*/
|
|
1324
|
+
function inlineMenus(parts, ctx, taken, nextId, dropped = { count: 0 }, all = parts) {
|
|
1325
|
+
const out = [];
|
|
1326
|
+
const ordered = parts.filter((p) => areaOf(p) && p.content?.raw).toSorted((a, b) => (AREA_ORDER.indexOf(areaOf(a)) + 1 || 99) - (AREA_ORDER.indexOf(areaOf(b)) + 1 || 99));
|
|
1327
|
+
for (const p of ordered) {
|
|
1328
|
+
const area = areaOf(p);
|
|
1329
|
+
const navs = [];
|
|
1330
|
+
const find = (blocks) => {
|
|
1331
|
+
for (const b of blocks) if (b.name === "core/navigation") {
|
|
1332
|
+
if (typeof b.attrs.ref !== "number" && b.children.length) navs.push(b);
|
|
1333
|
+
} else find(b.children);
|
|
1334
|
+
};
|
|
1335
|
+
find(partTree(p, all));
|
|
1336
|
+
navs.forEach((nav, i) => {
|
|
1337
|
+
const name = strip(nav.attrs.ariaLabel) || `${titleCase(area)} navigation${navs.length > 1 ? ` ${i + 1}` : ""}`;
|
|
1338
|
+
const base = slugOf(name) || `${area}-navigation`;
|
|
1339
|
+
let slug = base;
|
|
1340
|
+
for (let k = 2; taken.has(slug); k++) slug = `${base}-${k}`;
|
|
1341
|
+
taken.add(slug);
|
|
1342
|
+
const items = blockItems(nav.children, ctx, nextId, dropped);
|
|
1343
|
+
if (items.length) out.push({
|
|
1344
|
+
id: nextId(),
|
|
1345
|
+
slug,
|
|
1346
|
+
name,
|
|
1347
|
+
items,
|
|
1348
|
+
locations: [area]
|
|
1349
|
+
});
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
return out;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Where each navigation is shown: the template parts (`header`, `footer`) whose navigation block
|
|
1356
|
+
* refers to it. An empty navigation block without a `ref` shows the fallback — the most recent published one;
|
|
1357
|
+
* one with links of its own shows those (an inline menu, not a `wp_navigation` post).
|
|
1358
|
+
*/
|
|
1359
|
+
function navigationLocations(parts, navigations, all = parts) {
|
|
1360
|
+
const out = /* @__PURE__ */ new Map();
|
|
1361
|
+
const fallback = [...navigations].toSorted((a, b) => (b.date_gmt ?? "").localeCompare(a.date_gmt ?? "") || b.id - a.id)[0]?.id;
|
|
1362
|
+
const find = (blocks, area) => {
|
|
1363
|
+
for (const b of blocks) {
|
|
1364
|
+
if (b.name === "core/navigation") {
|
|
1365
|
+
const ref = typeof b.attrs.ref === "number" ? b.attrs.ref : b.children.length ? void 0 : fallback;
|
|
1366
|
+
if (ref !== void 0) (out.get(ref) ?? out.set(ref, /* @__PURE__ */ new Set()).get(ref)).add(area);
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
find(b.children, area);
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
for (const p of parts) {
|
|
1373
|
+
const area = areaOf(p);
|
|
1374
|
+
if (area && p.content?.raw) find(partTree(p, all), area);
|
|
1375
|
+
}
|
|
1376
|
+
return new Map([...out].map(([id, set]) => [id, [...set].toSorted()]));
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Published `wp_navigation` posts, then the inline navigations of the used template parts → RawMenu[], slugs kept
|
|
1380
|
+
* unique against the classic menus already read. `templates` says which parts are used (see `usedParts`).
|
|
1381
|
+
*/
|
|
1382
|
+
function blockMenus(navigations, allParts, ctx, taken, dropped = { count: 0 }, templates = []) {
|
|
1383
|
+
const parts = usedParts(allParts, templates);
|
|
1384
|
+
const published = navigations.filter((n) => (n.status ?? "publish") === "publish").toSorted((a, b) => a.id - b.id);
|
|
1385
|
+
const locations = navigationLocations(parts, published, allParts);
|
|
1386
|
+
let next = 0;
|
|
1387
|
+
const out = [];
|
|
1388
|
+
for (const n of published) {
|
|
1389
|
+
let slug = n.slug || `navigation-${n.id}`;
|
|
1390
|
+
while (taken.has(slug)) slug = `${slug}-nav`;
|
|
1391
|
+
taken.add(slug);
|
|
1392
|
+
const menu = {
|
|
1393
|
+
id: n.id,
|
|
1394
|
+
slug,
|
|
1395
|
+
name: strip(n.title?.raw || n.title?.rendered || "") || slug,
|
|
1396
|
+
items: navigationItems(n.content?.raw ?? "", ctx, () => -++next, dropped)
|
|
1397
|
+
};
|
|
1398
|
+
const where = locations.get(n.id);
|
|
1399
|
+
if (where?.length) menu.locations = where;
|
|
1400
|
+
out.push(menu);
|
|
1401
|
+
}
|
|
1402
|
+
out.push(...inlineMenus(parts, ctx, taken, () => -++next, dropped, allParts));
|
|
1403
|
+
return out;
|
|
1404
|
+
}
|
|
1405
|
+
//#endregion
|
|
678
1406
|
//#region src/rest.ts
|
|
679
1407
|
const iso = (gmt) => gmt ? `${gmt}Z` : null;
|
|
680
1408
|
const approvedOf = (status) => status === "approved" ? "1" : status === "hold" ? "0" : status === "spam" ? "spam" : status === "trash" ? "trash" : status ?? "1";
|
|
@@ -693,8 +1421,43 @@ const DENIED = new Set([
|
|
|
693
1421
|
401,
|
|
694
1422
|
403
|
|
695
1423
|
]);
|
|
1424
|
+
/** REST taxonomies that hold no content: menus, block-pattern categories, multilingual bookkeeping. */
|
|
1425
|
+
const NOT_CONTENT_TAXONOMIES = new Set([
|
|
1426
|
+
"category",
|
|
1427
|
+
"post_tag",
|
|
1428
|
+
"nav_menu",
|
|
1429
|
+
"wp_pattern_category",
|
|
1430
|
+
"post_format",
|
|
1431
|
+
"language",
|
|
1432
|
+
"post_translations",
|
|
1433
|
+
"term_language",
|
|
1434
|
+
"term_translations"
|
|
1435
|
+
]);
|
|
696
1436
|
/** A comment listing's name in `credential.fell_back`: `comments` (approved) or `comments:hold`. */
|
|
697
1437
|
const commentKey = (status) => status === "approve" ? "comments" : `comments:${status}`;
|
|
1438
|
+
/**
|
|
1439
|
+
* ACF fields of one REST post. `<name>_source` (SCF) states each field's type and label; a secret — a
|
|
1440
|
+
* `password` field, or, when no type is stated, a field named like one — is never read. ACF answers `[]`
|
|
1441
|
+
* for a post with no field group in REST.
|
|
1442
|
+
*/
|
|
1443
|
+
function acfOf(acf) {
|
|
1444
|
+
if (!acf || Array.isArray(acf) || typeof acf !== "object") return {};
|
|
1445
|
+
const out = {};
|
|
1446
|
+
for (const [name, value] of Object.entries(acf)) {
|
|
1447
|
+
if (name.endsWith("_source") && name.slice(0, -7) in acf) continue;
|
|
1448
|
+
const source = acf[`${name}_source`];
|
|
1449
|
+
const type = typeof source?.type === "string" ? source.type : void 0;
|
|
1450
|
+
if (acfIsSecret(name, type)) continue;
|
|
1451
|
+
out[name] = {
|
|
1452
|
+
value: acfScrub(value),
|
|
1453
|
+
...type ? { type } : {},
|
|
1454
|
+
...typeof source?.label === "string" && source.label ? { label: source.label } : {}
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
return Object.keys(out).length ? { acf: out } : {};
|
|
1458
|
+
}
|
|
1459
|
+
/** What a visitor can open: published, not behind a post password. */
|
|
1460
|
+
const visible = (p) => p.status === "publish" && !p.password;
|
|
698
1461
|
const positiveInteger = (name, value) => {
|
|
699
1462
|
if (!Number.isSafeInteger(value) || value < 1) throw new RangeError(`${name} must be a positive safe integer`);
|
|
700
1463
|
return value;
|
|
@@ -790,10 +1553,27 @@ async function fetchRestRawIR(options) {
|
|
|
790
1553
|
const rest = await Promise.all(Array.from({ length: wanted - 1 }, (_v, i) => pageData(i + 2).then((r) => r.items)));
|
|
791
1554
|
return firstPage.concat(...rest);
|
|
792
1555
|
};
|
|
793
|
-
const
|
|
1556
|
+
const about = await schedule(async () => {
|
|
1557
|
+
try {
|
|
1558
|
+
const index = await doFetch(`${origin}/wp-json/`, { headers });
|
|
1559
|
+
if (!index.ok) {
|
|
1560
|
+
await index.body?.cancel();
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
return await index.json();
|
|
1564
|
+
} catch {
|
|
1565
|
+
return null;
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
1568
|
+
let typesResp = await doFetch(`${origin}/wp-json/wp/v2/types${options.auth && !rejected ? "?context=edit" : ""}`, { headers });
|
|
1569
|
+
if (!typesResp.ok && options.auth && !rejected) {
|
|
1570
|
+
await typesResp.body?.cancel();
|
|
1571
|
+
typesResp = await doFetch(`${origin}/wp-json/wp/v2/types`, { headers: anonymous });
|
|
1572
|
+
}
|
|
794
1573
|
const types = typesResp.ok ? await typesResp.json() : {};
|
|
795
1574
|
if (!typesResp.ok) warnings.push(`types: HTTP ${typesResp.status} — importing posts and pages only`);
|
|
796
1575
|
const postTypes = Object.values(types).filter((t) => t.rest_base && !SKIP_TYPES.test(t.slug) && t.slug !== "attachment");
|
|
1576
|
+
const notViewable = new Set(postTypes.filter((t) => t.viewable === false).map((t) => t.slug));
|
|
797
1577
|
const bases = postTypes.length ? postTypes.map((t) => ({
|
|
798
1578
|
slug: t.slug,
|
|
799
1579
|
base: t.rest_base
|
|
@@ -817,16 +1597,39 @@ async function fetchRestRawIR(options) {
|
|
|
817
1597
|
for (const c of lists.flat()) if (!byId.has(c.id)) byId.set(c.id, c);
|
|
818
1598
|
return [...byId.values()];
|
|
819
1599
|
};
|
|
820
|
-
const
|
|
1600
|
+
const taxResp = await schedule(async () => {
|
|
1601
|
+
try {
|
|
1602
|
+
const r = await doFetch(`${origin}/wp-json/wp/v2/taxonomies`, { headers });
|
|
1603
|
+
if (!r.ok) {
|
|
1604
|
+
await r.body?.cancel();
|
|
1605
|
+
return {};
|
|
1606
|
+
}
|
|
1607
|
+
return await r.json();
|
|
1608
|
+
} catch {
|
|
1609
|
+
return {};
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
const extraTaxonomies = Object.values(taxResp && typeof taxResp === "object" ? taxResp : {}).filter((t) => t.rest_base && /^[\w-]+$/.test(t.rest_base) && !NOT_CONTENT_TAXONOMIES.has(t.slug)).map((t) => ({
|
|
1613
|
+
taxonomy: t.slug,
|
|
1614
|
+
base: t.rest_base
|
|
1615
|
+
})).toSorted((a, b) => a.taxonomy.localeCompare(b.taxonomy));
|
|
1616
|
+
const [categories, tags, users, media, restComments, ...lists] = await Promise.all([
|
|
821
1617
|
getAll("categories"),
|
|
822
1618
|
getAll("tags"),
|
|
823
1619
|
getAll("users"),
|
|
824
1620
|
getAll("media"),
|
|
825
1621
|
commentListing(),
|
|
1622
|
+
...extraTaxonomies.map((t) => getAll(t.base)),
|
|
826
1623
|
...bases.map((b) => postListing(b.base))
|
|
827
1624
|
]);
|
|
1625
|
+
const extraTerms = lists.slice(0, extraTaxonomies.length);
|
|
1626
|
+
const postLists = lists.slice(extraTaxonomies.length);
|
|
828
1627
|
const termsById = /* @__PURE__ */ new Map();
|
|
829
|
-
for (const [tax, list] of [
|
|
1628
|
+
for (const [tax, list] of [
|
|
1629
|
+
["category", categories],
|
|
1630
|
+
["post_tag", tags],
|
|
1631
|
+
...extraTaxonomies.map((t, i) => [t.taxonomy, extraTerms[i] ?? []])
|
|
1632
|
+
]) for (const t of list) termsById.set(t.id, {
|
|
830
1633
|
id: t.id,
|
|
831
1634
|
taxonomy: tax,
|
|
832
1635
|
slug: t.slug,
|
|
@@ -858,7 +1661,15 @@ async function fetchRestRawIR(options) {
|
|
|
858
1661
|
});
|
|
859
1662
|
};
|
|
860
1663
|
for (const [i, base] of bases.entries()) for (const p of postLists[i] ?? []) {
|
|
861
|
-
const
|
|
1664
|
+
const custom = extraTaxonomies.flatMap((t) => {
|
|
1665
|
+
const ids = p[t.base];
|
|
1666
|
+
return Array.isArray(ids) ? ids.filter((id) => typeof id === "number") : [];
|
|
1667
|
+
});
|
|
1668
|
+
const termRefs = [
|
|
1669
|
+
...p.categories ?? [],
|
|
1670
|
+
...p.tags ?? [],
|
|
1671
|
+
...custom
|
|
1672
|
+
].map((id) => {
|
|
862
1673
|
const t = termsById.get(id);
|
|
863
1674
|
return t ? {
|
|
864
1675
|
taxonomy: t.taxonomy,
|
|
@@ -884,7 +1695,7 @@ async function fetchRestRawIR(options) {
|
|
|
884
1695
|
status: p.status ?? "publish",
|
|
885
1696
|
slug: p.slug,
|
|
886
1697
|
title: p.title?.rendered ?? "",
|
|
887
|
-
link: p.link ?? null,
|
|
1698
|
+
link: notViewable.has(base.slug) ? null : p.link ?? null,
|
|
888
1699
|
guid: null,
|
|
889
1700
|
author,
|
|
890
1701
|
date: iso(p.date_gmt),
|
|
@@ -898,7 +1709,8 @@ async function fetchRestRawIR(options) {
|
|
|
898
1709
|
comment_status: p.comment_status ?? null,
|
|
899
1710
|
ping_status: p.ping_status ?? null,
|
|
900
1711
|
terms: termRefs,
|
|
901
|
-
meta
|
|
1712
|
+
meta,
|
|
1713
|
+
...acfOf(p.acf)
|
|
902
1714
|
});
|
|
903
1715
|
}
|
|
904
1716
|
const attachments = media.map((m) => ({
|
|
@@ -915,6 +1727,89 @@ async function fetchRestRawIR(options) {
|
|
|
915
1727
|
parent_resolved: m.post ? posts.some((p) => p.id === m.post) : null,
|
|
916
1728
|
date: iso(m.date_gmt)
|
|
917
1729
|
}));
|
|
1730
|
+
const gaps = [];
|
|
1731
|
+
if (postLists.some((list) => list.some((p) => p.acf !== void 0))) gaps.push("acf_partial");
|
|
1732
|
+
let menus = [];
|
|
1733
|
+
if (!authed) gaps.push("menus_require_auth");
|
|
1734
|
+
else {
|
|
1735
|
+
const listing = async (path) => {
|
|
1736
|
+
const url = (page) => `${origin}/wp-json/wp/v2/${path}${path.includes("?") ? "&" : "?"}per_page=${perPage}&page=${page}`;
|
|
1737
|
+
const first = await schedule(async () => {
|
|
1738
|
+
const r = await doFetch(url(1), { headers });
|
|
1739
|
+
if (!r.ok) {
|
|
1740
|
+
await r.body?.cancel();
|
|
1741
|
+
return {
|
|
1742
|
+
items: [],
|
|
1743
|
+
status: r.status,
|
|
1744
|
+
pages: 1
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
const items = await r.json();
|
|
1748
|
+
return {
|
|
1749
|
+
items: Array.isArray(items) ? items : [],
|
|
1750
|
+
status: r.status,
|
|
1751
|
+
pages: Math.max(1, Number(r.headers.get("x-wp-totalpages") ?? "1") || 1)
|
|
1752
|
+
};
|
|
1753
|
+
});
|
|
1754
|
+
const wanted = maxPages !== void 0 ? Math.min(first.pages, maxPages) : first.pages;
|
|
1755
|
+
const rest = await Promise.all(Array.from({ length: wanted - 1 }, (_v, i) => schedule(async () => {
|
|
1756
|
+
const r = await doFetch(url(i + 2), { headers });
|
|
1757
|
+
if (!r.ok) {
|
|
1758
|
+
await r.body?.cancel();
|
|
1759
|
+
warnings.push(`${path.split("?")[0]}: page ${i + 2} HTTP ${r.status} — skipped`);
|
|
1760
|
+
return [];
|
|
1761
|
+
}
|
|
1762
|
+
const items = await r.json();
|
|
1763
|
+
return Array.isArray(items) ? items : [];
|
|
1764
|
+
})));
|
|
1765
|
+
return {
|
|
1766
|
+
items: first.items.concat(...rest),
|
|
1767
|
+
status: first.status
|
|
1768
|
+
};
|
|
1769
|
+
};
|
|
1770
|
+
const [classic, items, navs, parts, templates] = await Promise.all([
|
|
1771
|
+
listing("menus?context=edit"),
|
|
1772
|
+
listing("menu-items?context=edit"),
|
|
1773
|
+
listing("navigation?context=edit&status=publish"),
|
|
1774
|
+
listing("template-parts?context=edit"),
|
|
1775
|
+
listing("templates?context=edit&_fields=slug,content")
|
|
1776
|
+
]);
|
|
1777
|
+
const denied = [
|
|
1778
|
+
classic,
|
|
1779
|
+
items,
|
|
1780
|
+
navs
|
|
1781
|
+
].filter((l) => DENIED.has(l.status));
|
|
1782
|
+
if (denied.length) {
|
|
1783
|
+
gaps.push("menus_require_auth");
|
|
1784
|
+
warnings.push(`menus: HTTP ${denied[0].status} with the credential — the user may not edit theme options; menus not read`);
|
|
1785
|
+
}
|
|
1786
|
+
for (const [name, l] of [
|
|
1787
|
+
["menus", classic],
|
|
1788
|
+
["menu-items", items],
|
|
1789
|
+
["navigation", navs]
|
|
1790
|
+
]) if (!l.status || l.status >= 400 && !DENIED.has(l.status) && l.status !== 404) warnings.push(`${name}: HTTP ${l.status} — skipped`);
|
|
1791
|
+
const slugOf = new Map(posts.filter(visible).map((p) => [p.id, p.slug]));
|
|
1792
|
+
const ctx = {
|
|
1793
|
+
origin,
|
|
1794
|
+
postSlug: (id) => slugOf.get(id),
|
|
1795
|
+
termSlug: (taxonomy, id) => {
|
|
1796
|
+
const t = termsById.get(id);
|
|
1797
|
+
return t && t.taxonomy === taxonomy ? t.slug : void 0;
|
|
1798
|
+
},
|
|
1799
|
+
pages: posts.filter((p) => p.type === "page" && p.status === "publish" && !p.password).map((p) => ({
|
|
1800
|
+
id: p.id,
|
|
1801
|
+
parent: p.parent ?? null,
|
|
1802
|
+
menu_order: p.menu_order ?? 0,
|
|
1803
|
+
title: strip(p.title),
|
|
1804
|
+
link: p.link ?? null,
|
|
1805
|
+
slug: p.slug
|
|
1806
|
+
}))
|
|
1807
|
+
};
|
|
1808
|
+
const dropped = { count: 0 };
|
|
1809
|
+
menus = classicMenus(DENIED.has(classic.status) ? [] : classic.items, DENIED.has(items.status) ? [] : items.items, ctx, dropped);
|
|
1810
|
+
menus.push(...blockMenus(DENIED.has(navs.status) ? [] : navs.items, parts.status < 400 ? parts.items : [], ctx, new Set(menus.map((m) => m.slug)), dropped, templates.status < 400 ? templates.items : []));
|
|
1811
|
+
if (dropped.count) warnings.push(`menus: ${dropped.count} item(s) are drafts or point at content not proven public (unpublished, password-protected, or not read by this import) — left out`);
|
|
1812
|
+
}
|
|
918
1813
|
const postIds = new Set(posts.map((p) => p.id));
|
|
919
1814
|
const commentIds = new Set(restComments.map((c) => c.id));
|
|
920
1815
|
const comments = restComments.map((c) => ({
|
|
@@ -941,7 +1836,15 @@ async function fetchRestRawIR(options) {
|
|
|
941
1836
|
kind: authed ? "rest_auth" : "rest_public",
|
|
942
1837
|
tool: options.tool ?? "@contentrain/wp-import"
|
|
943
1838
|
},
|
|
944
|
-
site: {
|
|
1839
|
+
site: {
|
|
1840
|
+
url: origin,
|
|
1841
|
+
...typeof about?.name === "string" && strip(about.name) ? { title: strip(about.name) } : {},
|
|
1842
|
+
...typeof about?.description === "string" && strip(about.description) ? { description: strip(about.description) } : {},
|
|
1843
|
+
...typeof about?.url === "string" && about.url ? { base_site_url: about.url } : {},
|
|
1844
|
+
...typeof about?.home === "string" && about.home ? { base_blog_url: about.home } : {},
|
|
1845
|
+
...typeof about?.timezone_string === "string" && about.timezone_string ? { timezone: about.timezone_string } : {},
|
|
1846
|
+
...about?.gmt_offset !== void 0 && about.gmt_offset !== null && about.gmt_offset !== "" && Number.isFinite(Number(about.gmt_offset)) ? { gmt_offset: Number(about.gmt_offset) } : {}
|
|
1847
|
+
},
|
|
945
1848
|
authors: users.map((u) => ({
|
|
946
1849
|
id: u.id,
|
|
947
1850
|
login: u.slug,
|
|
@@ -952,13 +1855,15 @@ async function fetchRestRawIR(options) {
|
|
|
952
1855
|
posts,
|
|
953
1856
|
attachments,
|
|
954
1857
|
comments,
|
|
1858
|
+
...menus.length ? { menus } : {},
|
|
955
1859
|
...languagePairs.length ? { language_pairs: languagePairs } : {}
|
|
956
1860
|
},
|
|
957
1861
|
warnings,
|
|
958
1862
|
credential: {
|
|
959
1863
|
status: !options.auth ? "none" : fellBack.size ? "rejected" : "accepted",
|
|
960
1864
|
fell_back: [...fellBack].toSorted()
|
|
961
|
-
}
|
|
1865
|
+
},
|
|
1866
|
+
gaps
|
|
962
1867
|
};
|
|
963
1868
|
}
|
|
964
1869
|
//#endregion
|
|
@@ -993,6 +1898,8 @@ function relationOver(targets) {
|
|
|
993
1898
|
ref: (entry) => single ? entry.ref : entry
|
|
994
1899
|
};
|
|
995
1900
|
}
|
|
1901
|
+
/** Post, term, user or attachment ids of an ACF reference value: ids, numeric strings or `{ ID }` objects. */
|
|
1902
|
+
const idsOf = (v) => (Array.isArray(v) ? v : [v]).map((x) => typeof x === "number" ? x : typeof x === "string" && /^\d+$/.test(x) ? Number(x) : x && typeof x === "object" ? Number(x.ID ?? x.id ?? x.term_id) : NaN).filter((n) => Number.isSafeInteger(n) && n > 0);
|
|
996
1903
|
function rawToContentrain(raw, opts) {
|
|
997
1904
|
const updatedBy = opts?.updatedBy ?? "@contentrain/wp-import";
|
|
998
1905
|
const locale = normLocale(raw.site.language) || "en";
|
|
@@ -1024,12 +1931,15 @@ function rawToContentrain(raw, opts) {
|
|
|
1024
1931
|
title_fallback: 0,
|
|
1025
1932
|
meta_fields: {},
|
|
1026
1933
|
acf_fields: {},
|
|
1934
|
+
acf_outside_choices: {},
|
|
1935
|
+
acf_datetime_unzoned: 0,
|
|
1027
1936
|
skipped_types: [],
|
|
1028
1937
|
dropped_relations: 0,
|
|
1029
1938
|
models: {},
|
|
1030
1939
|
locales,
|
|
1031
1940
|
translation_groups: translationGroups,
|
|
1032
|
-
password_protected_drafts: 0
|
|
1941
|
+
password_protected_drafts: 0,
|
|
1942
|
+
site_title_missing: false
|
|
1033
1943
|
};
|
|
1034
1944
|
const importMeta = (status, extra = {}) => ({
|
|
1035
1945
|
status,
|
|
@@ -1087,6 +1997,100 @@ function rawToContentrain(raw, opts) {
|
|
|
1087
1997
|
});
|
|
1088
1998
|
}
|
|
1089
1999
|
const mediaRef = (id) => raw.attachments.some((a) => a.id === id) ? hexId(`media:${id}`) : null;
|
|
2000
|
+
const termById = new Map(raw.terms.map((t) => [t.id, t]));
|
|
2001
|
+
const authorById = new Map(raw.authors.filter((a) => a.id !== null).map((a) => [a.id, a]));
|
|
2002
|
+
const refOf = (kind, id) => {
|
|
2003
|
+
if (kind === "post") return postEntry.get(id) ?? null;
|
|
2004
|
+
if (kind === "media") {
|
|
2005
|
+
const m = mediaRef(id);
|
|
2006
|
+
return m ? {
|
|
2007
|
+
model: "media",
|
|
2008
|
+
ref: m
|
|
2009
|
+
} : null;
|
|
2010
|
+
}
|
|
2011
|
+
if (kind === "user") {
|
|
2012
|
+
const a = authorById.get(id);
|
|
2013
|
+
return a && models.authors ? {
|
|
2014
|
+
model: "authors",
|
|
2015
|
+
ref: authorId(slugify(a.login))
|
|
2016
|
+
} : null;
|
|
2017
|
+
}
|
|
2018
|
+
if (kind === "term") {
|
|
2019
|
+
const t = termById.get(id);
|
|
2020
|
+
return t && models[taxModelId(t.taxonomy)] ? {
|
|
2021
|
+
model: taxModelId(t.taxonomy),
|
|
2022
|
+
ref: termId(t.taxonomy, t.slug)
|
|
2023
|
+
} : null;
|
|
2024
|
+
}
|
|
2025
|
+
return null;
|
|
2026
|
+
};
|
|
2027
|
+
/** Every ACF field on these posts: its definition, or — for a reference — its kind and target models. */
|
|
2028
|
+
const acfPlan = (items, taken) => {
|
|
2029
|
+
const out = /* @__PURE__ */ new Map();
|
|
2030
|
+
for (const p of items) for (const [k, acf] of Object.entries(p.acf ?? {})) {
|
|
2031
|
+
if (taken(k) || acf.value === null || acf.value === void 0 || acf.value === "" || acf.value === false && acf.type !== "true_false") continue;
|
|
2032
|
+
const kind = acf.type ? ACF_REFERENCE_TYPES[acf.type] : void 0;
|
|
2033
|
+
if (kind === "address") {
|
|
2034
|
+
const def = Array.isArray(acf.value) ? {
|
|
2035
|
+
type: "array",
|
|
2036
|
+
items: "url"
|
|
2037
|
+
} : { type: "url" };
|
|
2038
|
+
const cur = out.get(k);
|
|
2039
|
+
out.set(k, {
|
|
2040
|
+
def: cur?.def ?? {
|
|
2041
|
+
...def,
|
|
2042
|
+
...acf.label ? { label: strip(acf.label) } : {}
|
|
2043
|
+
},
|
|
2044
|
+
kind,
|
|
2045
|
+
multiple: false,
|
|
2046
|
+
models: /* @__PURE__ */ new Set()
|
|
2047
|
+
});
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
if (kind) {
|
|
2051
|
+
const cur = out.get(k) ?? {
|
|
2052
|
+
kind,
|
|
2053
|
+
multiple: acf.type === "relationship" || acf.type === "gallery",
|
|
2054
|
+
models: /* @__PURE__ */ new Set()
|
|
2055
|
+
};
|
|
2056
|
+
if (Array.isArray(acf.value)) cur.multiple = true;
|
|
2057
|
+
for (const id of idsOf(acf.value)) {
|
|
2058
|
+
const r = refOf(kind, id);
|
|
2059
|
+
if (r) cur.models.add(r.model);
|
|
2060
|
+
}
|
|
2061
|
+
out.set(k, cur);
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
const def = acfFieldDef(k, acfScrub(acf.value), {
|
|
2065
|
+
type: acf.type,
|
|
2066
|
+
label: acf.label
|
|
2067
|
+
});
|
|
2068
|
+
if (!def) continue;
|
|
2069
|
+
const cur = out.get(k);
|
|
2070
|
+
out.set(k, {
|
|
2071
|
+
def: cur?.def ? mergeFieldDef(cur.def, def) : def,
|
|
2072
|
+
multiple: false,
|
|
2073
|
+
models: /* @__PURE__ */ new Set()
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
for (const [k, plan] of out) if (!plan.def && !plan.models.size) out.delete(k);
|
|
2077
|
+
return out;
|
|
2078
|
+
};
|
|
2079
|
+
const acfRefs = (plan, value) => {
|
|
2080
|
+
const refs = idsOf(value).map((id) => refOf(plan.kind, id)).filter((r) => !!r);
|
|
2081
|
+
const missing = idsOf(value).length - refs.length;
|
|
2082
|
+
if (missing > 0) report.dropped_relations += missing;
|
|
2083
|
+
if (!refs.length) return void 0;
|
|
2084
|
+
const poly = plan.models.size > 1;
|
|
2085
|
+
const one = (r) => poly ? r : r.ref;
|
|
2086
|
+
return plan.multiple ? refs.map(one) : one(refs[0]);
|
|
2087
|
+
};
|
|
2088
|
+
const addressOf = (v) => {
|
|
2089
|
+
if (Array.isArray(v)) return v.map(addressOf).filter(Boolean);
|
|
2090
|
+
if (typeof v !== "number") return v;
|
|
2091
|
+
const t = postById.get(v);
|
|
2092
|
+
return t && t.status === "publish" && !t.password ? t.link ?? void 0 : void 0;
|
|
2093
|
+
};
|
|
1090
2094
|
const titleOf = (p) => {
|
|
1091
2095
|
const t = strip(p.title);
|
|
1092
2096
|
if (t) return t;
|
|
@@ -1426,31 +2430,32 @@ function rawToContentrain(raw, opts) {
|
|
|
1426
2430
|
label: "Comments open",
|
|
1427
2431
|
order: o += 10
|
|
1428
2432
|
};
|
|
1429
|
-
for (const p of typeItems) {
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
if (t && t !== "object") {
|
|
1434
|
-
fields[k] = {
|
|
1435
|
-
type: t,
|
|
1436
|
-
label: k,
|
|
1437
|
-
order: o += 10
|
|
1438
|
-
};
|
|
1439
|
-
report.meta_fields[k] = t;
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
for (const [k, acf] of Object.entries(p.acf ?? {})) {
|
|
1443
|
-
if (fields[k]) continue;
|
|
1444
|
-
const t = byValue(acf.value) ?? "string";
|
|
2433
|
+
for (const p of typeItems) for (const [k, v] of Object.entries(p.meta)) {
|
|
2434
|
+
if (k.startsWith("_") || fields[k] || PLUGIN_META.test(k) || k === "enclosure") continue;
|
|
2435
|
+
const t = byValue(v);
|
|
2436
|
+
if (t && t !== "object") {
|
|
1445
2437
|
fields[k] = {
|
|
1446
|
-
type: t
|
|
2438
|
+
type: t,
|
|
1447
2439
|
label: k,
|
|
1448
|
-
description: "ACF",
|
|
1449
2440
|
order: o += 10
|
|
1450
2441
|
};
|
|
1451
|
-
report.
|
|
2442
|
+
report.meta_fields[k] = t;
|
|
1452
2443
|
}
|
|
1453
2444
|
}
|
|
2445
|
+
const acfFields = acfPlan(typeItems, (k) => !!fields[k]);
|
|
2446
|
+
for (const [k, plan] of acfFields) {
|
|
2447
|
+
const def = plan.def ?? {
|
|
2448
|
+
type: plan.multiple ? "relations" : "relation",
|
|
2449
|
+
model: plan.models.size > 1 ? [...plan.models].toSorted() : [...plan.models][0]
|
|
2450
|
+
};
|
|
2451
|
+
fields[k] = {
|
|
2452
|
+
...def,
|
|
2453
|
+
...def.label ? {} : { label: k },
|
|
2454
|
+
description: def.description ?? "ACF",
|
|
2455
|
+
order: o += 10
|
|
2456
|
+
};
|
|
2457
|
+
report.acf_fields[k] = plan.def ? plan.def.type : `${def.type}:${[...plan.models].toSorted().join("|")}`;
|
|
2458
|
+
}
|
|
1454
2459
|
const covered = locales.filter((l) => l === locale || typeItems.some((p) => postLocale(p) === l));
|
|
1455
2460
|
addModel({
|
|
1456
2461
|
id: mid,
|
|
@@ -1499,7 +2504,22 @@ function rawToContentrain(raw, opts) {
|
|
|
1499
2504
|
if (fields.visibility) e.visibility = p.password ? "password" : p.status === "private" ? "private" : "public";
|
|
1500
2505
|
if (fields.comments_open) e.comments_open = p.comment_status === "open";
|
|
1501
2506
|
for (const k of Object.keys(fields)) if (k in p.meta && !(k in e) && !k.startsWith("_")) e[k] = p.meta[k];
|
|
1502
|
-
for (const [k, acf] of Object.entries(p.acf ?? {}))
|
|
2507
|
+
for (const [k, acf] of Object.entries(p.acf ?? {})) {
|
|
2508
|
+
const plan = acfFields.get(k);
|
|
2509
|
+
if (!plan || k in e) continue;
|
|
2510
|
+
const ctx = {
|
|
2511
|
+
timeZone: raw.site.timezone,
|
|
2512
|
+
gmtOffset: raw.site.gmt_offset,
|
|
2513
|
+
dropped: () => {
|
|
2514
|
+
report.acf_outside_choices[k] = (report.acf_outside_choices[k] ?? 0) + 1;
|
|
2515
|
+
},
|
|
2516
|
+
unzoned: () => {
|
|
2517
|
+
report.acf_datetime_unzoned++;
|
|
2518
|
+
}
|
|
2519
|
+
};
|
|
2520
|
+
const v = plan.def ? acfValue(plan.def, plan.kind === "address" ? addressOf(acf.value) : acfRows(acfScrub(acf.value)), ctx) : acfRefs(plan, acf.value);
|
|
2521
|
+
if (v !== void 0) e[k] = v;
|
|
2522
|
+
}
|
|
1503
2523
|
contentBucket[id] = e;
|
|
1504
2524
|
metaBucket[id] = mapStatus(p);
|
|
1505
2525
|
}
|
|
@@ -1537,7 +2557,13 @@ function rawToContentrain(raw, opts) {
|
|
|
1537
2557
|
type: "integer",
|
|
1538
2558
|
label: "WP term ID",
|
|
1539
2559
|
order: 40
|
|
1540
|
-
}
|
|
2560
|
+
},
|
|
2561
|
+
...menus.some((m) => m.locations?.length) ? { locations: {
|
|
2562
|
+
type: "array",
|
|
2563
|
+
items: "string",
|
|
2564
|
+
label: "Locations",
|
|
2565
|
+
order: 50
|
|
2566
|
+
} } : {}
|
|
1541
2567
|
}
|
|
1542
2568
|
});
|
|
1543
2569
|
const toTarget = relationOver([...contentModelIds, ...taxonomies.map(taxModelId)]);
|
|
@@ -1625,7 +2651,8 @@ function rawToContentrain(raw, opts) {
|
|
|
1625
2651
|
name: strip(m.name),
|
|
1626
2652
|
slug: slugify(m.slug) || `menu-${m.id}`,
|
|
1627
2653
|
items: m.items.map((i) => itemRef(i.id)),
|
|
1628
|
-
wp_id: m.id
|
|
2654
|
+
...m.id !== null && m.id > 0 ? { wp_id: m.id } : {},
|
|
2655
|
+
...m.locations?.length ? { locations: m.locations } : {}
|
|
1629
2656
|
};
|
|
1630
2657
|
metas.menus[mid] = importMeta("published");
|
|
1631
2658
|
for (const i of m.items) {
|
|
@@ -1634,7 +2661,7 @@ function rawToContentrain(raw, opts) {
|
|
|
1634
2661
|
menu: mid,
|
|
1635
2662
|
order: i.order,
|
|
1636
2663
|
type: i.target.kind === "url" ? "custom" : i.target.kind === "post" ? "post_type" : i.target.kind === "term" ? "taxonomy" : i.target.kind === "archive" ? "post_type_archive" : "custom",
|
|
1637
|
-
wp_id: i.id,
|
|
2664
|
+
...i.id > 0 ? { wp_id: i.id } : {},
|
|
1638
2665
|
open_in_new_tab: i.target_attr === "_blank"
|
|
1639
2666
|
};
|
|
1640
2667
|
if (i.parent && !i.parent_unresolved) e.parent = itemRef(i.parent);
|
|
@@ -1788,7 +2815,7 @@ function rawToContentrain(raw, opts) {
|
|
|
1788
2815
|
}
|
|
1789
2816
|
});
|
|
1790
2817
|
siteEntry = pick({
|
|
1791
|
-
title: strip(raw.site.title)
|
|
2818
|
+
title: strip(raw.site.title),
|
|
1792
2819
|
tagline: raw.site.description,
|
|
1793
2820
|
url: raw.site.url,
|
|
1794
2821
|
language: raw.site.language
|
|
@@ -1799,6 +2826,7 @@ function rawToContentrain(raw, opts) {
|
|
|
1799
2826
|
"language"
|
|
1800
2827
|
]));
|
|
1801
2828
|
siteMeta = importMeta("published");
|
|
2829
|
+
report.site_title_missing = !siteEntry.title;
|
|
1802
2830
|
const vocabTerms = {};
|
|
1803
2831
|
for (const m of menus) for (const i of m.items) {
|
|
1804
2832
|
const k = slugify(strip(i.title));
|