@contentrain/wp-import 0.7.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 +72 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +701 -42
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -675,6 +675,364 @@ 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
|
|
678
1036
|
//#region src/rest-menus.ts
|
|
679
1037
|
const targetOf = (kind, object, id, url, ctx) => {
|
|
680
1038
|
const objectId = id && id > 0 ? id : null;
|
|
@@ -733,12 +1091,33 @@ function hiddenItems(items, drop) {
|
|
|
733
1091
|
lift
|
|
734
1092
|
};
|
|
735
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
|
+
}
|
|
736
1109
|
/** Classic menus and their items; each menu names the theme locations it is assigned to. `dropped` counts left-out items. */
|
|
737
1110
|
function classicMenus(menus, items, ctx, dropped = { count: 0 }) {
|
|
738
1111
|
const out = [];
|
|
739
1112
|
for (const m of [...menus].toSorted((a, b) => a.id - b.id)) {
|
|
740
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);
|
|
741
|
-
const
|
|
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);
|
|
742
1121
|
dropped.count += gone.size;
|
|
743
1122
|
const own = all.filter((i) => !gone.has(i.id));
|
|
744
1123
|
const ids = new Set(own.map((i) => i.id));
|
|
@@ -805,6 +1184,7 @@ function parseBlocks(content) {
|
|
|
805
1184
|
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
806
1185
|
const absolute = (url, origin) => {
|
|
807
1186
|
if (!url) return void 0;
|
|
1187
|
+
if (url.startsWith("#")) return url;
|
|
808
1188
|
try {
|
|
809
1189
|
return new URL(url, `${origin}/`).href;
|
|
810
1190
|
} catch {
|
|
@@ -822,6 +1202,10 @@ const KIND = {
|
|
|
822
1202
|
* published pages it lists; a home link points at `/`. Other blocks (search, social icons, spacers) are not links.
|
|
823
1203
|
*/
|
|
824
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) {
|
|
825
1209
|
const items = [];
|
|
826
1210
|
const push = (b, parent) => {
|
|
827
1211
|
const id = nextId();
|
|
@@ -859,7 +1243,8 @@ function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
|
|
|
859
1243
|
if (b.name === "core/navigation-link" || b.name === "core/navigation-submenu") {
|
|
860
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");
|
|
861
1245
|
const object = kind === "taxonomy" && str(a.type) === "tag" ? "post_tag" : str(a.type);
|
|
862
|
-
|
|
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)) {
|
|
863
1248
|
dropped.count++;
|
|
864
1249
|
walk(b.children, parent);
|
|
865
1250
|
continue;
|
|
@@ -884,15 +1269,94 @@ function navigationItems(content, ctx, nextId, dropped = { count: 0 }) {
|
|
|
884
1269
|
else walk(b.children, parent);
|
|
885
1270
|
}
|
|
886
1271
|
};
|
|
887
|
-
walk(
|
|
1272
|
+
walk(tree, null);
|
|
888
1273
|
return items;
|
|
889
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
|
+
}
|
|
890
1354
|
/**
|
|
891
1355
|
* Where each navigation is shown: the template parts (`header`, `footer`) whose navigation block
|
|
892
1356
|
* refers to it. An empty navigation block without a `ref` shows the fallback — the most recent published one;
|
|
893
1357
|
* one with links of its own shows those (an inline menu, not a `wp_navigation` post).
|
|
894
1358
|
*/
|
|
895
|
-
function navigationLocations(parts, navigations) {
|
|
1359
|
+
function navigationLocations(parts, navigations, all = parts) {
|
|
896
1360
|
const out = /* @__PURE__ */ new Map();
|
|
897
1361
|
const fallback = [...navigations].toSorted((a, b) => (b.date_gmt ?? "").localeCompare(a.date_gmt ?? "") || b.id - a.id)[0]?.id;
|
|
898
1362
|
const find = (blocks, area) => {
|
|
@@ -906,15 +1370,19 @@ function navigationLocations(parts, navigations) {
|
|
|
906
1370
|
}
|
|
907
1371
|
};
|
|
908
1372
|
for (const p of parts) {
|
|
909
|
-
const area = p
|
|
910
|
-
if (area && p.content?.raw) find(
|
|
1373
|
+
const area = areaOf(p);
|
|
1374
|
+
if (area && p.content?.raw) find(partTree(p, all), area);
|
|
911
1375
|
}
|
|
912
1376
|
return new Map([...out].map(([id, set]) => [id, [...set].toSorted()]));
|
|
913
1377
|
}
|
|
914
|
-
/**
|
|
915
|
-
|
|
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);
|
|
916
1384
|
const published = navigations.filter((n) => (n.status ?? "publish") === "publish").toSorted((a, b) => a.id - b.id);
|
|
917
|
-
const locations = navigationLocations(parts, published);
|
|
1385
|
+
const locations = navigationLocations(parts, published, allParts);
|
|
918
1386
|
let next = 0;
|
|
919
1387
|
const out = [];
|
|
920
1388
|
for (const n of published) {
|
|
@@ -931,6 +1399,7 @@ function blockMenus(navigations, parts, ctx, taken, dropped = { count: 0 }) {
|
|
|
931
1399
|
if (where?.length) menu.locations = where;
|
|
932
1400
|
out.push(menu);
|
|
933
1401
|
}
|
|
1402
|
+
out.push(...inlineMenus(parts, ctx, taken, () => -++next, dropped, allParts));
|
|
934
1403
|
return out;
|
|
935
1404
|
}
|
|
936
1405
|
//#endregion
|
|
@@ -952,8 +1421,41 @@ const DENIED = new Set([
|
|
|
952
1421
|
401,
|
|
953
1422
|
403
|
|
954
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
|
+
]);
|
|
955
1436
|
/** A comment listing's name in `credential.fell_back`: `comments` (approved) or `comments:hold`. */
|
|
956
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
|
+
}
|
|
957
1459
|
/** What a visitor can open: published, not behind a post password. */
|
|
958
1460
|
const visible = (p) => p.status === "publish" && !p.password;
|
|
959
1461
|
const positiveInteger = (name, value) => {
|
|
@@ -1063,10 +1565,15 @@ async function fetchRestRawIR(options) {
|
|
|
1063
1565
|
return null;
|
|
1064
1566
|
}
|
|
1065
1567
|
});
|
|
1066
|
-
|
|
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
|
+
}
|
|
1067
1573
|
const types = typesResp.ok ? await typesResp.json() : {};
|
|
1068
1574
|
if (!typesResp.ok) warnings.push(`types: HTTP ${typesResp.status} — importing posts and pages only`);
|
|
1069
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));
|
|
1070
1577
|
const bases = postTypes.length ? postTypes.map((t) => ({
|
|
1071
1578
|
slug: t.slug,
|
|
1072
1579
|
base: t.rest_base
|
|
@@ -1090,16 +1597,39 @@ async function fetchRestRawIR(options) {
|
|
|
1090
1597
|
for (const c of lists.flat()) if (!byId.has(c.id)) byId.set(c.id, c);
|
|
1091
1598
|
return [...byId.values()];
|
|
1092
1599
|
};
|
|
1093
|
-
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([
|
|
1094
1617
|
getAll("categories"),
|
|
1095
1618
|
getAll("tags"),
|
|
1096
1619
|
getAll("users"),
|
|
1097
1620
|
getAll("media"),
|
|
1098
1621
|
commentListing(),
|
|
1622
|
+
...extraTaxonomies.map((t) => getAll(t.base)),
|
|
1099
1623
|
...bases.map((b) => postListing(b.base))
|
|
1100
1624
|
]);
|
|
1625
|
+
const extraTerms = lists.slice(0, extraTaxonomies.length);
|
|
1626
|
+
const postLists = lists.slice(extraTaxonomies.length);
|
|
1101
1627
|
const termsById = /* @__PURE__ */ new Map();
|
|
1102
|
-
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, {
|
|
1103
1633
|
id: t.id,
|
|
1104
1634
|
taxonomy: tax,
|
|
1105
1635
|
slug: t.slug,
|
|
@@ -1131,7 +1661,15 @@ async function fetchRestRawIR(options) {
|
|
|
1131
1661
|
});
|
|
1132
1662
|
};
|
|
1133
1663
|
for (const [i, base] of bases.entries()) for (const p of postLists[i] ?? []) {
|
|
1134
|
-
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) => {
|
|
1135
1673
|
const t = termsById.get(id);
|
|
1136
1674
|
return t ? {
|
|
1137
1675
|
taxonomy: t.taxonomy,
|
|
@@ -1157,7 +1695,7 @@ async function fetchRestRawIR(options) {
|
|
|
1157
1695
|
status: p.status ?? "publish",
|
|
1158
1696
|
slug: p.slug,
|
|
1159
1697
|
title: p.title?.rendered ?? "",
|
|
1160
|
-
link: p.link ?? null,
|
|
1698
|
+
link: notViewable.has(base.slug) ? null : p.link ?? null,
|
|
1161
1699
|
guid: null,
|
|
1162
1700
|
author,
|
|
1163
1701
|
date: iso(p.date_gmt),
|
|
@@ -1171,7 +1709,8 @@ async function fetchRestRawIR(options) {
|
|
|
1171
1709
|
comment_status: p.comment_status ?? null,
|
|
1172
1710
|
ping_status: p.ping_status ?? null,
|
|
1173
1711
|
terms: termRefs,
|
|
1174
|
-
meta
|
|
1712
|
+
meta,
|
|
1713
|
+
...acfOf(p.acf)
|
|
1175
1714
|
});
|
|
1176
1715
|
}
|
|
1177
1716
|
const attachments = media.map((m) => ({
|
|
@@ -1189,6 +1728,7 @@ async function fetchRestRawIR(options) {
|
|
|
1189
1728
|
date: iso(m.date_gmt)
|
|
1190
1729
|
}));
|
|
1191
1730
|
const gaps = [];
|
|
1731
|
+
if (postLists.some((list) => list.some((p) => p.acf !== void 0))) gaps.push("acf_partial");
|
|
1192
1732
|
let menus = [];
|
|
1193
1733
|
if (!authed) gaps.push("menus_require_auth");
|
|
1194
1734
|
else {
|
|
@@ -1227,11 +1767,12 @@ async function fetchRestRawIR(options) {
|
|
|
1227
1767
|
status: first.status
|
|
1228
1768
|
};
|
|
1229
1769
|
};
|
|
1230
|
-
const [classic, items, navs, parts] = await Promise.all([
|
|
1770
|
+
const [classic, items, navs, parts, templates] = await Promise.all([
|
|
1231
1771
|
listing("menus?context=edit"),
|
|
1232
1772
|
listing("menu-items?context=edit"),
|
|
1233
1773
|
listing("navigation?context=edit&status=publish"),
|
|
1234
|
-
listing("template-parts?context=edit")
|
|
1774
|
+
listing("template-parts?context=edit"),
|
|
1775
|
+
listing("templates?context=edit&_fields=slug,content")
|
|
1235
1776
|
]);
|
|
1236
1777
|
const denied = [
|
|
1237
1778
|
classic,
|
|
@@ -1266,7 +1807,7 @@ async function fetchRestRawIR(options) {
|
|
|
1266
1807
|
};
|
|
1267
1808
|
const dropped = { count: 0 };
|
|
1268
1809
|
menus = classicMenus(DENIED.has(classic.status) ? [] : classic.items, DENIED.has(items.status) ? [] : items.items, ctx, dropped);
|
|
1269
|
-
menus.push(...blockMenus(DENIED.has(navs.status) ? [] : navs.items, parts.status < 400 ? parts.items : [], ctx, new Set(menus.map((m) => m.slug)), 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 : []));
|
|
1270
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`);
|
|
1271
1812
|
}
|
|
1272
1813
|
const postIds = new Set(posts.map((p) => p.id));
|
|
@@ -1300,7 +1841,9 @@ async function fetchRestRawIR(options) {
|
|
|
1300
1841
|
...typeof about?.name === "string" && strip(about.name) ? { title: strip(about.name) } : {},
|
|
1301
1842
|
...typeof about?.description === "string" && strip(about.description) ? { description: strip(about.description) } : {},
|
|
1302
1843
|
...typeof about?.url === "string" && about.url ? { base_site_url: about.url } : {},
|
|
1303
|
-
...typeof about?.home === "string" && about.home ? { base_blog_url: about.home } : {}
|
|
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) } : {}
|
|
1304
1847
|
},
|
|
1305
1848
|
authors: users.map((u) => ({
|
|
1306
1849
|
id: u.id,
|
|
@@ -1355,6 +1898,8 @@ function relationOver(targets) {
|
|
|
1355
1898
|
ref: (entry) => single ? entry.ref : entry
|
|
1356
1899
|
};
|
|
1357
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);
|
|
1358
1903
|
function rawToContentrain(raw, opts) {
|
|
1359
1904
|
const updatedBy = opts?.updatedBy ?? "@contentrain/wp-import";
|
|
1360
1905
|
const locale = normLocale(raw.site.language) || "en";
|
|
@@ -1386,12 +1931,15 @@ function rawToContentrain(raw, opts) {
|
|
|
1386
1931
|
title_fallback: 0,
|
|
1387
1932
|
meta_fields: {},
|
|
1388
1933
|
acf_fields: {},
|
|
1934
|
+
acf_outside_choices: {},
|
|
1935
|
+
acf_datetime_unzoned: 0,
|
|
1389
1936
|
skipped_types: [],
|
|
1390
1937
|
dropped_relations: 0,
|
|
1391
1938
|
models: {},
|
|
1392
1939
|
locales,
|
|
1393
1940
|
translation_groups: translationGroups,
|
|
1394
|
-
password_protected_drafts: 0
|
|
1941
|
+
password_protected_drafts: 0,
|
|
1942
|
+
site_title_missing: false
|
|
1395
1943
|
};
|
|
1396
1944
|
const importMeta = (status, extra = {}) => ({
|
|
1397
1945
|
status,
|
|
@@ -1449,6 +1997,100 @@ function rawToContentrain(raw, opts) {
|
|
|
1449
1997
|
});
|
|
1450
1998
|
}
|
|
1451
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
|
+
};
|
|
1452
2094
|
const titleOf = (p) => {
|
|
1453
2095
|
const t = strip(p.title);
|
|
1454
2096
|
if (t) return t;
|
|
@@ -1788,31 +2430,32 @@ function rawToContentrain(raw, opts) {
|
|
|
1788
2430
|
label: "Comments open",
|
|
1789
2431
|
order: o += 10
|
|
1790
2432
|
};
|
|
1791
|
-
for (const p of typeItems) {
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
if (t && t !== "object") {
|
|
1796
|
-
fields[k] = {
|
|
1797
|
-
type: t,
|
|
1798
|
-
label: k,
|
|
1799
|
-
order: o += 10
|
|
1800
|
-
};
|
|
1801
|
-
report.meta_fields[k] = t;
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
for (const [k, acf] of Object.entries(p.acf ?? {})) {
|
|
1805
|
-
if (fields[k]) continue;
|
|
1806
|
-
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") {
|
|
1807
2437
|
fields[k] = {
|
|
1808
|
-
type: t
|
|
2438
|
+
type: t,
|
|
1809
2439
|
label: k,
|
|
1810
|
-
description: "ACF",
|
|
1811
2440
|
order: o += 10
|
|
1812
2441
|
};
|
|
1813
|
-
report.
|
|
2442
|
+
report.meta_fields[k] = t;
|
|
1814
2443
|
}
|
|
1815
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
|
+
}
|
|
1816
2459
|
const covered = locales.filter((l) => l === locale || typeItems.some((p) => postLocale(p) === l));
|
|
1817
2460
|
addModel({
|
|
1818
2461
|
id: mid,
|
|
@@ -1861,7 +2504,22 @@ function rawToContentrain(raw, opts) {
|
|
|
1861
2504
|
if (fields.visibility) e.visibility = p.password ? "password" : p.status === "private" ? "private" : "public";
|
|
1862
2505
|
if (fields.comments_open) e.comments_open = p.comment_status === "open";
|
|
1863
2506
|
for (const k of Object.keys(fields)) if (k in p.meta && !(k in e) && !k.startsWith("_")) e[k] = p.meta[k];
|
|
1864
|
-
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
|
+
}
|
|
1865
2523
|
contentBucket[id] = e;
|
|
1866
2524
|
metaBucket[id] = mapStatus(p);
|
|
1867
2525
|
}
|
|
@@ -1993,7 +2651,7 @@ function rawToContentrain(raw, opts) {
|
|
|
1993
2651
|
name: strip(m.name),
|
|
1994
2652
|
slug: slugify(m.slug) || `menu-${m.id}`,
|
|
1995
2653
|
items: m.items.map((i) => itemRef(i.id)),
|
|
1996
|
-
wp_id: m.id,
|
|
2654
|
+
...m.id !== null && m.id > 0 ? { wp_id: m.id } : {},
|
|
1997
2655
|
...m.locations?.length ? { locations: m.locations } : {}
|
|
1998
2656
|
};
|
|
1999
2657
|
metas.menus[mid] = importMeta("published");
|
|
@@ -2157,7 +2815,7 @@ function rawToContentrain(raw, opts) {
|
|
|
2157
2815
|
}
|
|
2158
2816
|
});
|
|
2159
2817
|
siteEntry = pick({
|
|
2160
|
-
title: strip(raw.site.title)
|
|
2818
|
+
title: strip(raw.site.title),
|
|
2161
2819
|
tagline: raw.site.description,
|
|
2162
2820
|
url: raw.site.url,
|
|
2163
2821
|
language: raw.site.language
|
|
@@ -2168,6 +2826,7 @@ function rawToContentrain(raw, opts) {
|
|
|
2168
2826
|
"language"
|
|
2169
2827
|
]));
|
|
2170
2828
|
siteMeta = importMeta("published");
|
|
2829
|
+
report.site_title_missing = !siteEntry.title;
|
|
2171
2830
|
const vocabTerms = {};
|
|
2172
2831
|
for (const m of menus) for (const i of m.items) {
|
|
2173
2832
|
const k = slugify(strip(i.title));
|