@acms-subir/core 0.3.15 → 0.3.17
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/CHANGELOG.md +12 -0
- package/cli/common.mjs +2 -2
- package/cli/main.mjs +10 -3
- package/cli/migrations.generated.mjs +49 -1
- package/cli/remote.mjs +1 -1
- package/cli/sync.mjs +876 -0
- package/docs/environment-sync.md +302 -0
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/components/DesignAccess.tsx +3 -0
- package/src/components/ThemeSectionsEditor.tsx +53 -46
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +3 -0
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/schema.ts +1 -0
- package/src/lib/sync/auth.ts +24 -0
- package/src/lib/sync/catalog.ts +250 -0
- package/src/lib/sync/migration.ts +51 -0
- package/src/lib/sync/protocol.mjs +125 -0
- package/src/lib/sync/service.ts +994 -0
- package/src/lib/sync/store.ts +349 -0
- package/src/pages/admin/integrations.astro +2 -0
- package/src/pages/api/design/keys.ts +7 -3
- package/src/pages/api/sync/index.ts +114 -0
- package/src/pages/api/sync/media.ts +90 -0
- package/src/styles/theme-editor.css +2 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
Page,
|
|
4
|
+
Post,
|
|
5
|
+
Calendar,
|
|
6
|
+
CalEvent,
|
|
7
|
+
Resource,
|
|
8
|
+
BookingLink,
|
|
9
|
+
Place,
|
|
10
|
+
Media,
|
|
11
|
+
NavItem,
|
|
12
|
+
Redirect,
|
|
13
|
+
Tag,
|
|
14
|
+
Settings,
|
|
15
|
+
CalendarSettings,
|
|
16
|
+
} from "../schema";
|
|
17
|
+
import { SavedForm } from "../forms/schema";
|
|
18
|
+
import { DesignPatch } from "../design-service";
|
|
19
|
+
import { SectionPages } from "../../theme-sdk/contract.mjs";
|
|
20
|
+
import { pickPageDraft, pickPostDraft, pickEventDraft } from "../drafts";
|
|
21
|
+
import { ApiError } from "../api";
|
|
22
|
+
|
|
23
|
+
export type Data = Record<string, any>;
|
|
24
|
+
const common = { id: true, createdAt: true, updatedAt: true } as const;
|
|
25
|
+
export const schemas = {
|
|
26
|
+
page: Page.omit({ ...common, draft: true })
|
|
27
|
+
.extend({ layout: z.unknown().nullable() })
|
|
28
|
+
.strict(),
|
|
29
|
+
post: Post.omit({ ...common, draft: true, authorId: true }).strict(),
|
|
30
|
+
calendar: Calendar.omit({
|
|
31
|
+
...common,
|
|
32
|
+
feedToken: true,
|
|
33
|
+
feedUrl: true,
|
|
34
|
+
}).strict(),
|
|
35
|
+
event: CalEvent.omit({
|
|
36
|
+
...common,
|
|
37
|
+
draft: true,
|
|
38
|
+
startAt: true,
|
|
39
|
+
endAt: true,
|
|
40
|
+
sourceUid: true,
|
|
41
|
+
}).strict(),
|
|
42
|
+
resource: Resource.omit({
|
|
43
|
+
...common,
|
|
44
|
+
feedToken: true,
|
|
45
|
+
busyFeeds: true,
|
|
46
|
+
notifyEmails: true,
|
|
47
|
+
}).strict(),
|
|
48
|
+
booking: BookingLink.omit(common).strict(),
|
|
49
|
+
place: Place.omit(common).strict(),
|
|
50
|
+
form: SavedForm.omit({ ...common, draft: true, revision: true }).strict(),
|
|
51
|
+
media: Media.omit({ id: true, createdAt: true })
|
|
52
|
+
.extend({
|
|
53
|
+
objects: z
|
|
54
|
+
.array(
|
|
55
|
+
z
|
|
56
|
+
.object({
|
|
57
|
+
key: z.string(),
|
|
58
|
+
digest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
59
|
+
size: z.number().int().nonnegative(),
|
|
60
|
+
})
|
|
61
|
+
.strict(),
|
|
62
|
+
)
|
|
63
|
+
.max(20),
|
|
64
|
+
})
|
|
65
|
+
.strict(),
|
|
66
|
+
nav: NavItem.omit({ id: true }).strict(),
|
|
67
|
+
redirect: Redirect.omit({ id: true, createdAt: true }).strict(),
|
|
68
|
+
tag: Tag.omit({ id: true }).strict(),
|
|
69
|
+
design: DesignPatch.omit({ sectionPages: true, navItems: true }).strict(),
|
|
70
|
+
settings: Settings.pick({
|
|
71
|
+
siteTitle: true,
|
|
72
|
+
tagline: true,
|
|
73
|
+
footerText: true,
|
|
74
|
+
timezone: true,
|
|
75
|
+
locale: true,
|
|
76
|
+
announcement: true,
|
|
77
|
+
}).strict(),
|
|
78
|
+
"booking-settings": CalendarSettings.strict(),
|
|
79
|
+
// Bylines can be linked to existing profiles; accounts never transfer.
|
|
80
|
+
author: z.object({ name: z.string(), slug: z.string() }).strict(),
|
|
81
|
+
};
|
|
82
|
+
export type Kind = keyof typeof schemas;
|
|
83
|
+
export const tables: Partial<Record<Kind, string>> = {
|
|
84
|
+
page: "pages",
|
|
85
|
+
post: "posts",
|
|
86
|
+
calendar: "calendars",
|
|
87
|
+
event: "events",
|
|
88
|
+
resource: "resources",
|
|
89
|
+
booking: "booking_links",
|
|
90
|
+
place: "places",
|
|
91
|
+
form: "forms",
|
|
92
|
+
media: "media",
|
|
93
|
+
nav: "nav_items",
|
|
94
|
+
redirect: "redirects",
|
|
95
|
+
tag: "tags",
|
|
96
|
+
author: "users",
|
|
97
|
+
};
|
|
98
|
+
export const settingsKinds = new Set<Kind>([
|
|
99
|
+
"design",
|
|
100
|
+
"settings",
|
|
101
|
+
"booking-settings",
|
|
102
|
+
]);
|
|
103
|
+
export const draftKinds = new Set<Kind>([
|
|
104
|
+
"page",
|
|
105
|
+
"post",
|
|
106
|
+
"event",
|
|
107
|
+
"form",
|
|
108
|
+
"design",
|
|
109
|
+
]);
|
|
110
|
+
const special: Record<string, string> = {
|
|
111
|
+
order: "ord",
|
|
112
|
+
start: "starts",
|
|
113
|
+
end: "ends",
|
|
114
|
+
};
|
|
115
|
+
export const column = (field: string) =>
|
|
116
|
+
special[field] ?? field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
117
|
+
const jsonFields = new Set([
|
|
118
|
+
"blocks",
|
|
119
|
+
"translations",
|
|
120
|
+
"tags",
|
|
121
|
+
"calendarIds",
|
|
122
|
+
"fields",
|
|
123
|
+
"location",
|
|
124
|
+
"cta",
|
|
125
|
+
"rsvp",
|
|
126
|
+
"roomHold",
|
|
127
|
+
"exdates",
|
|
128
|
+
"amenities",
|
|
129
|
+
"hours",
|
|
130
|
+
"overrides",
|
|
131
|
+
"lengths",
|
|
132
|
+
"approvalRules",
|
|
133
|
+
"questions",
|
|
134
|
+
"variants",
|
|
135
|
+
"definition",
|
|
136
|
+
"designOverrides",
|
|
137
|
+
"announcement",
|
|
138
|
+
]);
|
|
139
|
+
const boolFields = new Set([
|
|
140
|
+
"isHome",
|
|
141
|
+
"featured",
|
|
142
|
+
"allDay",
|
|
143
|
+
"cancelled",
|
|
144
|
+
"showOnCalendar",
|
|
145
|
+
"isDefault",
|
|
146
|
+
"autoConfirm",
|
|
147
|
+
"askPhone",
|
|
148
|
+
"active",
|
|
149
|
+
]);
|
|
150
|
+
export function fields(kind: Kind) {
|
|
151
|
+
return Object.keys(schemas[kind].shape).filter(
|
|
152
|
+
(k) => !["layout", "objects", "authorIds"].includes(k),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
export const encode = (value: any) =>
|
|
156
|
+
value === null
|
|
157
|
+
? null
|
|
158
|
+
: typeof value === "boolean"
|
|
159
|
+
? Number(value)
|
|
160
|
+
: typeof value === "object"
|
|
161
|
+
? JSON.stringify(value)
|
|
162
|
+
: value;
|
|
163
|
+
export function rowData(kind: Kind, row: Data): Data {
|
|
164
|
+
const data: Data = {};
|
|
165
|
+
for (const key of fields(kind)) {
|
|
166
|
+
let value = row[column(key)];
|
|
167
|
+
if (value === undefined) continue;
|
|
168
|
+
if (jsonFields.has(key) && typeof value === "string")
|
|
169
|
+
value = JSON.parse(value);
|
|
170
|
+
if (boolFields.has(key)) value = Boolean(value);
|
|
171
|
+
data[key] = value;
|
|
172
|
+
}
|
|
173
|
+
return data;
|
|
174
|
+
}
|
|
175
|
+
export function parseData(kind: Kind, input: unknown): Data {
|
|
176
|
+
const data = schemas[kind].parse(input) as Data;
|
|
177
|
+
if (kind === "page" && data.layout !== null)
|
|
178
|
+
SectionPages.parse({ page: data.layout });
|
|
179
|
+
if (
|
|
180
|
+
["page", "post", "calendar", "event", "resource", "booking"].includes(
|
|
181
|
+
kind,
|
|
182
|
+
) &&
|
|
183
|
+
!/^[a-z0-9][a-z0-9-]{0,199}$/.test(data.slug)
|
|
184
|
+
)
|
|
185
|
+
throw new ApiError(400, "Sync requires a valid content slug.");
|
|
186
|
+
if (kind === "media") {
|
|
187
|
+
for (const key of [
|
|
188
|
+
data.key,
|
|
189
|
+
...data.variants.map((v: Data) => v.key),
|
|
190
|
+
...data.objects.map((o: Data) => o.key),
|
|
191
|
+
])
|
|
192
|
+
safeMediaKey(key);
|
|
193
|
+
const required = [data.key, ...data.variants.map((v: Data) => v.key)];
|
|
194
|
+
if (
|
|
195
|
+
data.objects.length !== required.length ||
|
|
196
|
+
required.some(
|
|
197
|
+
(k) => data.objects.filter((o: Data) => o.key === k).length !== 1,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
throw new ApiError(
|
|
201
|
+
400,
|
|
202
|
+
"Media checksums must cover each file exactly once.",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return data;
|
|
206
|
+
}
|
|
207
|
+
export function safeMediaKey(key: string) {
|
|
208
|
+
if (
|
|
209
|
+
!/^[a-zA-Z0-9_./-]+$/.test(key) ||
|
|
210
|
+
key.startsWith("/") ||
|
|
211
|
+
key.split("/").some((p) => !p || p === "." || p === "..") ||
|
|
212
|
+
key.length > 500
|
|
213
|
+
)
|
|
214
|
+
throw new ApiError(400, "Invalid media storage key.");
|
|
215
|
+
return key;
|
|
216
|
+
}
|
|
217
|
+
export function effective(kind: Kind, live: Data, draft: Data | null): Data {
|
|
218
|
+
if (!draft) return live;
|
|
219
|
+
const pick =
|
|
220
|
+
kind === "page"
|
|
221
|
+
? pickPageDraft
|
|
222
|
+
: kind === "post"
|
|
223
|
+
? pickPostDraft
|
|
224
|
+
: kind === "event"
|
|
225
|
+
? pickEventDraft
|
|
226
|
+
: null;
|
|
227
|
+
if (pick) return { ...live, ...pick(draft) };
|
|
228
|
+
if (kind === "form") return { ...live, definition: draft };
|
|
229
|
+
if (kind === "design")
|
|
230
|
+
return {
|
|
231
|
+
...live,
|
|
232
|
+
...Object.fromEntries(
|
|
233
|
+
Object.entries(draft).filter(([k]) => fields("design").includes(k)),
|
|
234
|
+
),
|
|
235
|
+
};
|
|
236
|
+
return live;
|
|
237
|
+
}
|
|
238
|
+
export function matchKey(kind: Kind, data: Data) {
|
|
239
|
+
if (kind === "post") return `${data.collection}/${data.slug}`;
|
|
240
|
+
if (kind === "booking") return `${data.resourceId}/${data.slug}`;
|
|
241
|
+
if (kind === "nav") return `${data.area}/${data.url}`;
|
|
242
|
+
if (kind === "redirect") return data.fromPath;
|
|
243
|
+
if (kind === "media") return data.objects?.[0]?.digest ?? data.key;
|
|
244
|
+
return data.slug || data.name || kind;
|
|
245
|
+
}
|
|
246
|
+
export function displayRef(kind: Kind, data: Data) {
|
|
247
|
+
if (settingsKinds.has(kind)) return `${kind}:site`;
|
|
248
|
+
if (kind === "post") return `post:${data.collection}/${data.slug}`;
|
|
249
|
+
return `${kind}:${data.slug || data.name || data.filename || data.fromPath || data.label}`;
|
|
250
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const tables = [
|
|
2
|
+
"pages",
|
|
3
|
+
"posts",
|
|
4
|
+
"post_authors",
|
|
5
|
+
"calendars",
|
|
6
|
+
"events",
|
|
7
|
+
"resources",
|
|
8
|
+
"booking_links",
|
|
9
|
+
"places",
|
|
10
|
+
"forms",
|
|
11
|
+
"media",
|
|
12
|
+
"nav_items",
|
|
13
|
+
"redirects",
|
|
14
|
+
"tags",
|
|
15
|
+
"users",
|
|
16
|
+
"sync_aliases",
|
|
17
|
+
"bookings",
|
|
18
|
+
"form_submissions",
|
|
19
|
+
];
|
|
20
|
+
export const SYNC_MIGRATION = {
|
|
21
|
+
version: 52,
|
|
22
|
+
statements: [
|
|
23
|
+
`CREATE TABLE sync_state (id INTEGER PRIMARY KEY CHECK(id=1), site_id TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 0)`,
|
|
24
|
+
`INSERT INTO sync_state (id,site_id) VALUES (1,lower(hex(randomblob(16))))`,
|
|
25
|
+
`CREATE TABLE sync_aliases (alias TEXT PRIMARY KEY, kind TEXT NOT NULL, local_id TEXT NOT NULL, canonical INTEGER NOT NULL DEFAULT 0)`,
|
|
26
|
+
`CREATE UNIQUE INDEX sync_canonical ON sync_aliases(kind,local_id) WHERE canonical=1`,
|
|
27
|
+
`CREATE TABLE sync_versions (entity TEXT PRIMARY KEY, revision INTEGER NOT NULL)`,
|
|
28
|
+
`CREATE INDEX sync_aliases_local ON sync_aliases(kind,local_id)`,
|
|
29
|
+
`CREATE TABLE sync_plans (id TEXT PRIMARY KEY, digest TEXT NOT NULL, actor TEXT NOT NULL, body TEXT NOT NULL, result TEXT, created_at INTEGER NOT NULL)`,
|
|
30
|
+
...tables.flatMap((table) =>
|
|
31
|
+
["INSERT", "UPDATE", "DELETE"].map((op) => {
|
|
32
|
+
// Operational activity invalidates a plan without being copied into
|
|
33
|
+
// content history. Deleted content does not need permanent tombstones.
|
|
34
|
+
const operational = [
|
|
35
|
+
"bookings",
|
|
36
|
+
"form_submissions",
|
|
37
|
+
"sync_aliases",
|
|
38
|
+
].includes(table);
|
|
39
|
+
const entity = `'${table}:' || ${op === "DELETE" ? "OLD" : "NEW"}.${table === "post_authors" ? "post_id" : "id"}`;
|
|
40
|
+
const version = operational
|
|
41
|
+
? ""
|
|
42
|
+
: op === "DELETE" && table !== "post_authors"
|
|
43
|
+
? `DELETE FROM sync_versions WHERE entity=${entity};`
|
|
44
|
+
: `INSERT INTO sync_versions(entity,revision) SELECT ${entity},revision FROM sync_state WHERE id=1 ON CONFLICT(entity) DO UPDATE SET revision=excluded.revision;`;
|
|
45
|
+
return `CREATE TRIGGER sync_${table}_${op.toLowerCase()} AFTER ${op} ON ${table} BEGIN UPDATE sync_state SET revision=revision+1 WHERE id=1; ${version} END`;
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
// Ignore cache counters and scheduler/integration bookkeeping.
|
|
49
|
+
`CREATE TRIGGER sync_settings_update AFTER UPDATE OF site_title,tagline,footer_text,timezone,locale,announcement,active_theme,design_overrides,section_pages,site_logo,site_logo_dark,site_favicon,site_cover,draft_design,calendar ON settings BEGIN UPDATE sync_state SET revision=revision+1 WHERE id=1; INSERT INTO sync_versions(entity,revision) SELECT 'settings:1',revision FROM sync_state WHERE id=1 ON CONFLICT(entity) DO UPDATE SET revision=excluded.revision; END`,
|
|
50
|
+
],
|
|
51
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Shared, runtime-independent sync vocabulary. This module ships with the CLI.
|
|
2
|
+
export const PROTOCOL = 1;
|
|
3
|
+
export const TYPES = {
|
|
4
|
+
pages: "page",
|
|
5
|
+
posts: "post",
|
|
6
|
+
design: "design",
|
|
7
|
+
navigation: "nav",
|
|
8
|
+
media: "media",
|
|
9
|
+
redirects: "redirect",
|
|
10
|
+
calendars: "calendar",
|
|
11
|
+
events: "event",
|
|
12
|
+
resources: "resource",
|
|
13
|
+
forms: "form",
|
|
14
|
+
"booking-config": "booking",
|
|
15
|
+
"site-settings": "settings",
|
|
16
|
+
places: "place",
|
|
17
|
+
tags: "tag",
|
|
18
|
+
};
|
|
19
|
+
export const canonical = (value) => JSON.stringify(sort(value));
|
|
20
|
+
function sort(value) {
|
|
21
|
+
if (Array.isArray(value)) return value.map(sort);
|
|
22
|
+
if (value && typeof value === "object")
|
|
23
|
+
return Object.fromEntries(
|
|
24
|
+
Object.keys(value)
|
|
25
|
+
.sort()
|
|
26
|
+
.map((k) => [k, sort(value[k])]),
|
|
27
|
+
);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
export async function hash(value) {
|
|
31
|
+
const bytes =
|
|
32
|
+
typeof value === "string"
|
|
33
|
+
? new TextEncoder().encode(value)
|
|
34
|
+
: value instanceof Uint8Array
|
|
35
|
+
? value
|
|
36
|
+
: new TextEncoder().encode(canonical(value));
|
|
37
|
+
return Array.from(
|
|
38
|
+
new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)),
|
|
39
|
+
(b) => b.toString(16).padStart(2, "0"),
|
|
40
|
+
).join("");
|
|
41
|
+
}
|
|
42
|
+
export function differences(before, after, path = "") {
|
|
43
|
+
if (canonical(before ?? null) === canonical(after ?? null)) return [];
|
|
44
|
+
if (
|
|
45
|
+
before &&
|
|
46
|
+
after &&
|
|
47
|
+
!Array.isArray(before) &&
|
|
48
|
+
!Array.isArray(after) &&
|
|
49
|
+
typeof before === "object" &&
|
|
50
|
+
typeof after === "object"
|
|
51
|
+
) {
|
|
52
|
+
return [
|
|
53
|
+
...new Set([...Object.keys(before), ...Object.keys(after)]),
|
|
54
|
+
].flatMap((k) =>
|
|
55
|
+
differences(before[k], after[k], path ? `${path}.${k}` : k),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return [
|
|
59
|
+
{ field: path || "(item)", before: before ?? null, after: after ?? null },
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
export function matchEntity(source, destination) {
|
|
63
|
+
const identity = destination.filter(
|
|
64
|
+
(d) =>
|
|
65
|
+
d.kind === source.kind &&
|
|
66
|
+
d.aliases.some((a) => source.aliases.includes(a)),
|
|
67
|
+
);
|
|
68
|
+
if (identity.length > 1) return { ambiguous: true };
|
|
69
|
+
if (identity.length) return { entity: identity[0], linked: true };
|
|
70
|
+
const named = destination.filter(
|
|
71
|
+
(d) => d.kind === source.kind && d.match === source.match,
|
|
72
|
+
);
|
|
73
|
+
return named.length > 1
|
|
74
|
+
? { ambiguous: true }
|
|
75
|
+
: { entity: named[0], linked: false };
|
|
76
|
+
}
|
|
77
|
+
export function classify(source, match, baseline, resolution) {
|
|
78
|
+
if (match.ambiguous)
|
|
79
|
+
return {
|
|
80
|
+
action: "conflict",
|
|
81
|
+
reason:
|
|
82
|
+
"Ambiguous destination match; resolve duplicate names on the destination.",
|
|
83
|
+
};
|
|
84
|
+
const target = match.entity;
|
|
85
|
+
if (resolution?.use === "destination")
|
|
86
|
+
return { action: "preserve", reason: "Keep destination" };
|
|
87
|
+
if (!target)
|
|
88
|
+
return baseline && resolution?.use !== "source"
|
|
89
|
+
? {
|
|
90
|
+
action: "conflict",
|
|
91
|
+
reason: "Previously transferred item was deleted on the destination.",
|
|
92
|
+
}
|
|
93
|
+
: { action: "create", reason: "New item" };
|
|
94
|
+
if (source.hash === target.hash)
|
|
95
|
+
return { action: "skip", reason: "Unchanged" };
|
|
96
|
+
if (resolution?.use === "source" || resolution?.data)
|
|
97
|
+
return { action: "update", reason: "Explicitly resolved" };
|
|
98
|
+
if (!match.linked && !baseline)
|
|
99
|
+
return {
|
|
100
|
+
action: "conflict",
|
|
101
|
+
reason:
|
|
102
|
+
"First sync: confirm the existing destination item before linking it.",
|
|
103
|
+
};
|
|
104
|
+
if (!baseline)
|
|
105
|
+
return {
|
|
106
|
+
action: "conflict",
|
|
107
|
+
reason: "No baseline for this direction; review before overwriting.",
|
|
108
|
+
};
|
|
109
|
+
if (source.hash === baseline.source)
|
|
110
|
+
return { action: "preserve", reason: "Only destination changed" };
|
|
111
|
+
if (target.hash !== baseline.destination)
|
|
112
|
+
return { action: "conflict", reason: "Both environments changed" };
|
|
113
|
+
return { action: "update", reason: "Source changed" };
|
|
114
|
+
}
|
|
115
|
+
// References are explicit tokens, never guessed from titles on the receiving site.
|
|
116
|
+
export const reference = (key) => `acms-sync:${key}`;
|
|
117
|
+
export function transform(value, visit) {
|
|
118
|
+
if (typeof value === "string") return visit(value);
|
|
119
|
+
if (Array.isArray(value)) return value.map((v) => transform(v, visit));
|
|
120
|
+
if (value && typeof value === "object")
|
|
121
|
+
return Object.fromEntries(
|
|
122
|
+
Object.entries(value).map(([k, v]) => [k, transform(v, visit)]),
|
|
123
|
+
);
|
|
124
|
+
return value;
|
|
125
|
+
}
|