@gramps-connect/story 0.1.3 → 0.3.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.
@@ -0,0 +1,80 @@
1
+ import type { MediaItem, Slide, SlideMedia, StoryDate, StoryDoc } from "../types";
2
+ /** A story with one empty slide: the smallest valid one. */
3
+ export declare function blankStory(title: string, language?: string): StoryDoc;
4
+ /** "s<n>" past every id already of that form, so it's unique however the
5
+ * story's other ids were made. */
6
+ export declare function nextSlideId(doc: StoryDoc): string;
7
+ /** A new empty slide after `index`; returns the story and the new slide's
8
+ * index. */
9
+ export declare function addSlide(doc: StoryDoc, index: number): {
10
+ doc: StoryDoc;
11
+ index: number;
12
+ };
13
+ /** A copy of the slide right after it, with its own id. */
14
+ export declare function duplicateSlide(doc: StoryDoc, index: number): {
15
+ doc: StoryDoc;
16
+ index: number;
17
+ };
18
+ /** Removes a slide, unless it's the only one (a story has at least one). */
19
+ export declare function deleteSlide(doc: StoryDoc, index: number): {
20
+ doc: StoryDoc;
21
+ index: number;
22
+ };
23
+ export declare function moveSlide(doc: StoryDoc, from: number, to: number): StoryDoc;
24
+ export declare function updateSlide(doc: StoryDoc, index: number, patch: Partial<Slide>): StoryDoc;
25
+ export declare function updateStory(doc: StoryDoc, patch: Partial<Omit<StoryDoc, "slides" | "media">>): StoryDoc;
26
+ /** A slide date from what was typed and its precise form. Empty text is no
27
+ * date at all. A new precise form drops a `sortYear` it would contradict
28
+ * (the timeline reads sortYear first); the date's other fields stay. */
29
+ export declare function slideDate(previous: StoryDate | undefined, text: string, edtf: string | undefined): StoryDate | undefined;
30
+ /** A prepared image to add to a story (see optimizeImage). */
31
+ export interface NewImage {
32
+ data: Uint8Array;
33
+ mime: string;
34
+ /** File extension, without the dot. */
35
+ ext: string;
36
+ width?: number;
37
+ height?: number;
38
+ }
39
+ /** The media key whose file is exactly these bytes, if any: a photo
40
+ * that's already in the story, chosen again. */
41
+ export declare function findMedia(doc: StoryDoc, files: Map<string, Uint8Array>, data: Uint8Array): string | undefined;
42
+ /** Adds an image to the story's media (not to any slide), or finds the
43
+ * identical one already there, so the same photo is stored once. Returns
44
+ * its media key. */
45
+ export declare function addImage(doc: StoryDoc, files: Map<string, Uint8Array>, image: NewImage, stem?: string): {
46
+ doc: StoryDoc;
47
+ files: Map<string, Uint8Array>;
48
+ key: string;
49
+ };
50
+ /** A slide's photo list; an empty one removes the field. */
51
+ export declare function setSlidePhotos(doc: StoryDoc, index: number, media: SlideMedia[]): StoryDoc;
52
+ /** Changes a media item itself (its description, shared by every slide
53
+ * that shows it). */
54
+ export declare function updateMedia(doc: StoryDoc, key: string, patch: Partial<MediaItem>): StoryDoc;
55
+ /** Every media key a slide or overlay refers to. */
56
+ export declare function usedMedia(doc: StoryDoc): Set<string>;
57
+ /** The story as it should be saved: media nothing refers to any more left
58
+ * out, and the files only those that are still used. */
59
+ export declare function pruneMedia(doc: StoryDoc, files: Map<string, Uint8Array>): {
60
+ doc: StoryDoc;
61
+ files: Map<string, Uint8Array>;
62
+ };
63
+ /** "Josef Meyer" -> "Josef Meyer.story.zip", minus characters some file
64
+ * systems refuse. */
65
+ export declare function storyFileName(title: string): string;
66
+ /** Undo and redo over whole documents. Typing in one field makes one step,
67
+ * not one per keystroke: a change with the same `key` within COALESCE_MS
68
+ * of the last replaces it. */
69
+ export interface History {
70
+ past: StoryDoc[];
71
+ present: StoryDoc;
72
+ future: StoryDoc[];
73
+ lastKey?: string;
74
+ lastAt?: number;
75
+ }
76
+ export declare const COALESCE_MS = 1500;
77
+ export declare function startHistory(doc: StoryDoc): History;
78
+ export declare function record(history: History, next: StoryDoc, key?: string, now?: number): History;
79
+ export declare function undo(history: History): History;
80
+ export declare function redo(history: History): History;
@@ -0,0 +1,52 @@
1
+ export interface EditorStrings {
2
+ story: string;
3
+ storySettings: string;
4
+ title: string;
5
+ language: string;
6
+ languageHint: string;
7
+ map: string;
8
+ mapStandard: string;
9
+ mapHistorical: string;
10
+ mapAuto: string;
11
+ mapNone: string;
12
+ slides: string;
13
+ untitledSlide: string;
14
+ addSlide: string;
15
+ duplicateSlide: string;
16
+ deleteSlide: string;
17
+ moveUp: string;
18
+ moveDown: string;
19
+ slideTitle: string;
20
+ text: string;
21
+ bold: string;
22
+ italic: string;
23
+ date: string;
24
+ dateHint: string;
25
+ preciseDate: string;
26
+ preciseDateHint: string;
27
+ preciseDateFollows: string;
28
+ preciseDateNotUnderstood: string;
29
+ matchDateAsWritten: string;
30
+ noPreciseDate: string;
31
+ place: string;
32
+ photos: string;
33
+ photosHint: string;
34
+ addPhoto: string;
35
+ replacePhoto: string;
36
+ movePhotoUp: string;
37
+ movePhotoDown: string;
38
+ removePhoto: string;
39
+ caption: string;
40
+ credit: string;
41
+ creditHint: string;
42
+ description: string;
43
+ descriptionHint: string;
44
+ photoFailed: (name: string) => string;
45
+ undo: string;
46
+ redo: string;
47
+ present: string;
48
+ preview: string;
49
+ problems: (count: number) => string;
50
+ openingSlide: string;
51
+ }
52
+ export declare const EDITOR_ENGLISH: EditorStrings;
@@ -28,6 +28,9 @@ export interface StoryPlayerProps {
28
28
  renderMapModeControl?: (props: MapModeControlProps) => ReactNode;
29
29
  /** Shown before the title, e.g. ViewerBrand on a standalone page. */
30
30
  brand?: ReactNode;
31
+ /** The host's own buttons, at the right of the title bar (before the
32
+ * close button), e.g. the viewer site's Edit. */
33
+ actions?: ReactNode;
31
34
  initialSlide?: number;
32
35
  }
33
- export declare function StoryPlayer({ story, resolveMedia, dark, colors: colorOverrides, strings: stringOverrides, onClose, keyboard, onSlideChange, renderMapModeControl, brand, initialSlide, }: StoryPlayerProps): import("react").JSX.Element;
36
+ export declare function StoryPlayer({ story, resolveMedia, dark, colors: colorOverrides, strings: stringOverrides, onClose, keyboard, onSlideChange, renderMapModeControl, brand, actions, initialSlide, }: StoryPlayerProps): import("react").JSX.Element;
@@ -10,6 +10,8 @@ export interface PlayerStrings {
10
10
  historicalMapYear: (year: number) => string;
11
11
  noDateStandardMap: string;
12
12
  historicalYear: string;
13
+ /** A photo's thumbnail button, on a slide with several. */
14
+ photoOf: (n: number, total: number) => string;
13
15
  }
14
16
  export declare const ENGLISH: PlayerStrings;
15
17
  /** The best table for a list of BCP 47 tags, most preferred first:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gramps-connect/story",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "Read, write, validate and present .story.zip files: portable, versioned family-history stories.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -31,6 +31,11 @@
31
31
  "types": "./dist/types/player/index.d.ts",
32
32
  "default": "./dist/player.js"
33
33
  },
34
+ "./editor": {
35
+ "source": "./src/editor/index.ts",
36
+ "types": "./dist/types/editor/index.d.ts",
37
+ "default": "./dist/editor.js"
38
+ },
34
39
  "./embed": "./dist/embed.js",
35
40
  "./schema/1.0": "./spec/1.0/story.schema.json",
36
41
  "./package.json": "./package.json"
@@ -51,17 +56,20 @@
51
56
  "build:site": "node scripts/build.mjs && node scripts/build-site.mjs",
52
57
  "prepack": "node scripts/build.mjs",
53
58
  "generate": "node scripts/generate-validator.mjs",
54
- "test": "tsx --test src/__tests__/*.test.ts",
59
+ "test": "tsx --conditions=source --test src/__tests__/*.test.ts",
55
60
  "typecheck": "tsc --noEmit",
56
- "test:browser": "node scripts/test-image.mjs && node scripts/test-player.mjs && npm run build:site && node scripts/test-embed.mjs && node scripts/test-site.mjs",
61
+ "test:browser": "node scripts/test-image.mjs && node scripts/test-player.mjs && npm run build:site && node scripts/test-embed.mjs && node scripts/test-site.mjs && node scripts/test-editor.mjs",
57
62
  "test:pack": "node scripts/test-pack.mjs",
58
63
  "test:python": "${PYTHON:-python3} -m unittest discover -s python && tsx scripts/test-python.ts"
59
64
  },
60
65
  "dependencies": {
61
- "fflate": "^0.8.3",
62
- "@openhistoricalmap/maplibre-gl-dates": "^1.4.0"
66
+ "@gramps-connect/gramps-date": "^0.1.0",
67
+ "@openhistoricalmap/maplibre-gl-dates": "^1.4.0",
68
+ "fflate": "^0.8.3"
63
69
  },
64
70
  "devDependencies": {
71
+ "@mantine/core": "^8.3.18",
72
+ "@mantine/hooks": "^8.3.18",
65
73
  "@types/geojson": "^7946.0.16",
66
74
  "@types/node": "^26.1.2",
67
75
  "@types/react": "^18.3.12",
@@ -76,11 +84,19 @@
76
84
  "typescript": "^7.0.2"
77
85
  },
78
86
  "peerDependencies": {
87
+ "@mantine/core": "^8",
88
+ "@mantine/hooks": "^8",
79
89
  "maplibre-gl": ">=5",
80
90
  "react": "^18.2 || ^19",
81
91
  "react-dom": "^18.2 || ^19"
82
92
  },
83
93
  "peerDependenciesMeta": {
94
+ "@mantine/core": {
95
+ "optional": true
96
+ },
97
+ "@mantine/hooks": {
98
+ "optional": true
99
+ },
84
100
  "maplibre-gl": {
85
101
  "optional": true
86
102
  },
@@ -0,0 +1,95 @@
1
+ // A slide's date: the text readers see, and its precise form (EDTF), which
2
+ // follows the text as it's typed until someone changes it by hand.
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Anchor, Group, Stack, Text, TextInput } from "@mantine/core";
5
+ import type { StoryDate } from "../types";
6
+ import { edtfYearRange } from "../player/dates";
7
+ import type { DateTextParser } from "./dates";
8
+ import { slideDate } from "./model";
9
+ import type { EditorStrings } from "./strings";
10
+
11
+ export interface DateFieldProps {
12
+ value: StoryDate | undefined;
13
+ language: string | undefined;
14
+ parse: DateTextParser;
15
+ strings: EditorStrings;
16
+ onChange: (date: StoryDate | undefined) => void;
17
+ }
18
+
19
+ /** Keyed by slide in the parent, so `following` starts over per slide. */
20
+ export function DateField({ value, language, parse, strings, onChange }: DateFieldProps) {
21
+ // Whether the precise form is worked out from the text. Unknown (null)
22
+ // until the stored one has been compared with what the text gives.
23
+ const [following, setFollowing] = useState<boolean | null>(value?.edtf ? null : true);
24
+ const latest = useRef(value);
25
+ latest.current = value;
26
+ const request = useRef(0);
27
+
28
+ useEffect(() => {
29
+ if (following !== null) return;
30
+ let live = true;
31
+ void parse(value?.text ?? "", language).then((edtf) => {
32
+ if (live) setFollowing(edtf === value?.edtf);
33
+ });
34
+ return () => { live = false; };
35
+ // Once, for the stored date.
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, []);
38
+
39
+ const follow = (text: string) => {
40
+ const id = ++request.current;
41
+ void parse(text, language).then((edtf) => {
42
+ // Only the latest text's answer, and only onto the date as it is now.
43
+ if (id !== request.current || latest.current?.text !== text) return;
44
+ onChange(slideDate(latest.current, text, edtf));
45
+ });
46
+ };
47
+
48
+ const onText = (text: string) => {
49
+ onChange(slideDate(latest.current, text, latest.current?.edtf));
50
+ if (following !== false) follow(text);
51
+ };
52
+
53
+ const edtf = value?.edtf ?? "";
54
+ const understood = !edtf || edtfYearRange(edtf) !== null;
55
+ let note: string;
56
+ if (edtf && !understood) note = strings.preciseDateNotUnderstood;
57
+ else if (!edtf) note = strings.noPreciseDate;
58
+ else if (following) note = strings.preciseDateFollows;
59
+ else note = strings.preciseDateHint;
60
+
61
+ return (
62
+ <Stack gap={6}>
63
+ <TextInput
64
+ label={strings.date}
65
+ description={strings.dateHint}
66
+ value={value?.text ?? ""}
67
+ onChange={(e) => onText(e.currentTarget.value)}
68
+ />
69
+ {value?.text && (
70
+ <Group gap="sm" align="flex-end" wrap="nowrap">
71
+ <TextInput
72
+ label={strings.preciseDate}
73
+ size="xs"
74
+ w={200}
75
+ value={edtf}
76
+ error={!understood}
77
+ styles={{ input: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" } }}
78
+ onChange={(e) => {
79
+ setFollowing(false);
80
+ onChange(slideDate(latest.current, latest.current?.text ?? "", e.currentTarget.value.trim() || undefined));
81
+ }}
82
+ />
83
+ <Stack gap={0} pb={4}>
84
+ <Text size="xs" c={understood ? "dimmed" : "red"}>{note}</Text>
85
+ {following === false && (
86
+ <Anchor component="button" type="button" size="xs" onClick={() => { setFollowing(true); follow(value.text); }}>
87
+ {strings.matchDateAsWritten}
88
+ </Anchor>
89
+ )}
90
+ </Stack>
91
+ </Group>
92
+ )}
93
+ </Stack>
94
+ );
95
+ }
@@ -0,0 +1,134 @@
1
+ // A slide's photos: add (pick, drop or paste -- pasting is the editor's),
2
+ // replace, reorder and remove them, and write each one's caption, credit
3
+ // and description. The files themselves are the editor's to prepare and
4
+ // store; this only says what was chosen.
5
+ import { useEffect, useRef, useState, type DragEvent } from "react";
6
+ import { ActionIcon, Box, Button, Group, Loader, Stack, Text, TextInput, Tooltip } from "@mantine/core";
7
+ import type { MediaItem, SlideMedia } from "../types";
8
+ import type { EditorStrings } from "./strings";
9
+
10
+ export interface PhotosFieldProps {
11
+ photos: SlideMedia[];
12
+ media: Record<string, MediaItem>;
13
+ /** A displayable URL for a media key. */
14
+ urlFor: (key: string) => Promise<string | null>;
15
+ strings: EditorStrings;
16
+ /** True while chosen files are being prepared. */
17
+ busy?: boolean;
18
+ onChange: (photos: SlideMedia[], key: string) => void;
19
+ /** Image files to add at the end, or to put in place of photo `replace`. */
20
+ onFiles: (files: File[], replace?: number) => void;
21
+ onDescription: (mediaKey: string, alt: string) => void;
22
+ }
23
+
24
+ const imagesIn = (list: FileList | null | undefined) => [...(list ?? [])].filter((f) => f.type.startsWith("image/"));
25
+
26
+ function Preview({ mediaKey, urlFor }: { mediaKey: string; urlFor: PhotosFieldProps["urlFor"] }) {
27
+ const [url, setUrl] = useState<string | null>(null);
28
+ useEffect(() => {
29
+ let live = true;
30
+ void urlFor(mediaKey).then((u) => { if (live) setUrl(u); });
31
+ return () => { live = false; };
32
+ }, [mediaKey, urlFor]);
33
+ return (
34
+ <Box w={96} h={96} style={{ flex: "none", borderRadius: 6, overflow: "hidden", background: "var(--mantine-color-default-hover)" }}>
35
+ {url && <img src={url} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />}
36
+ </Box>
37
+ );
38
+ }
39
+
40
+ export function PhotosField({ photos, media, urlFor, strings, busy, onChange, onFiles, onDescription }: PhotosFieldProps) {
41
+ const input = useRef<HTMLInputElement>(null);
42
+ const replacing = useRef<number | undefined>(undefined);
43
+ const [dropping, setDropping] = useState(false);
44
+
45
+ const pick = (replace?: number) => {
46
+ replacing.current = replace;
47
+ input.current?.click();
48
+ };
49
+ const set = (i: number, patch: Partial<SlideMedia>, field: string) => {
50
+ const next = photos.map((p, j) => {
51
+ if (j !== i) return p;
52
+ const changed: Record<string, unknown> = { ...p, ...patch };
53
+ for (const [k, v] of Object.entries(patch)) if (!v) delete changed[k];
54
+ return changed as unknown as SlideMedia;
55
+ });
56
+ onChange(next, `photo:${i}:${field}`);
57
+ };
58
+ const move = (from: number, to: number) => {
59
+ const next = [...photos];
60
+ const [p] = next.splice(from, 1);
61
+ next.splice(to, 0, p);
62
+ onChange(next, "photos");
63
+ };
64
+ const onDrop = (e: DragEvent) => {
65
+ e.preventDefault();
66
+ setDropping(false);
67
+ const files = imagesIn(e.dataTransfer.files);
68
+ if (files.length) onFiles(files);
69
+ };
70
+
71
+ return (
72
+ <Stack
73
+ gap="sm"
74
+ onDragOver={(e) => { if ([...e.dataTransfer.types].includes("Files")) { e.preventDefault(); setDropping(true); } }}
75
+ onDragLeave={(e) => { if (e.currentTarget === e.target) setDropping(false); }}
76
+ onDrop={onDrop}
77
+ style={{
78
+ borderRadius: 8, padding: dropping ? 8 : 0, margin: dropping ? -8 : 0,
79
+ outline: dropping ? "2px dashed var(--mantine-primary-color-filled)" : undefined,
80
+ }}
81
+ >
82
+ <Group justify="space-between">
83
+ <Text size="sm" fw={500}>{strings.photos}</Text>
84
+ <Group gap="xs">
85
+ {busy && <Loader size="xs" />}
86
+ <Button variant="default" size="xs" onClick={() => pick()} disabled={busy}>+ {strings.addPhoto}</Button>
87
+ </Group>
88
+ </Group>
89
+ <input
90
+ ref={input} type="file" accept="image/*" multiple hidden
91
+ onChange={(e) => {
92
+ const files = imagesIn(e.currentTarget.files);
93
+ e.currentTarget.value = "";
94
+ if (files.length) onFiles(replacing.current === undefined ? files : files.slice(0, 1), replacing.current);
95
+ }}
96
+ />
97
+ {photos.length === 0 && <Text size="xs" c="dimmed">{strings.photosHint}</Text>}
98
+ {photos.map((photo, i) => {
99
+ const item = Object.prototype.hasOwnProperty.call(media, photo.ref) ? media[photo.ref] : undefined;
100
+ return (
101
+ <Group key={i} gap="sm" align="flex-start" wrap="nowrap">
102
+ <Stack gap={4} align="center">
103
+ <Preview mediaKey={photo.ref} urlFor={urlFor} />
104
+ <Group gap={4}>
105
+ <Tooltip label={strings.movePhotoUp}>
106
+ <ActionIcon size="sm" variant="default" aria-label={strings.movePhotoUp} disabled={i === 0} onClick={() => move(i, i - 1)}>↑</ActionIcon>
107
+ </Tooltip>
108
+ <Tooltip label={strings.movePhotoDown}>
109
+ <ActionIcon size="sm" variant="default" aria-label={strings.movePhotoDown} disabled={i === photos.length - 1} onClick={() => move(i, i + 1)}>↓</ActionIcon>
110
+ </Tooltip>
111
+ </Group>
112
+ </Stack>
113
+ <Stack gap={6} style={{ flex: 1, minWidth: 0 }}>
114
+ <TextInput size="xs" label={strings.caption} value={photo.caption ?? ""} onChange={(e) => set(i, { caption: e.currentTarget.value }, "caption")} />
115
+ <TextInput size="xs" label={strings.credit} placeholder={strings.creditHint} value={photo.credit ?? ""} onChange={(e) => set(i, { credit: e.currentTarget.value }, "credit")} />
116
+ {item && (
117
+ <TextInput
118
+ size="xs" label={strings.description} description={strings.descriptionHint} value={item.alt ?? ""}
119
+ onChange={(e) => onDescription(photo.ref, e.currentTarget.value)}
120
+ />
121
+ )}
122
+ <Group gap="xs">
123
+ <Button variant="subtle" size="compact-xs" onClick={() => pick(i)} disabled={busy}>{strings.replacePhoto}</Button>
124
+ <Button variant="subtle" size="compact-xs" color="red" onClick={() => onChange(photos.filter((_, j) => j !== i), "photos")}>
125
+ {strings.removePhoto}
126
+ </Button>
127
+ </Group>
128
+ </Stack>
129
+ </Group>
130
+ );
131
+ })}
132
+ </Stack>
133
+ );
134
+ }
@@ -0,0 +1,54 @@
1
+ // A list of slides to pick from and reorder by dragging -- generic over
2
+ // what a slide is, so any story editor can use it with its own rows.
3
+ import { useState, type ReactNode } from "react";
4
+ import { Stack, UnstyledButton } from "@mantine/core";
5
+
6
+ export interface SlideListProps<T> {
7
+ items: T[];
8
+ selected: number;
9
+ onSelect: (index: number) => void;
10
+ /** Called when a row is dropped somewhere else. */
11
+ onMove: (from: number, to: number) => void;
12
+ getKey: (item: T, index: number) => string;
13
+ renderItem: (item: T, index: number) => ReactNode;
14
+ label?: string;
15
+ }
16
+
17
+ export function SlideList<T>({ items, selected, onSelect, onMove, getKey, renderItem, label }: SlideListProps<T>) {
18
+ const [dragging, setDragging] = useState<number | null>(null);
19
+ const [over, setOver] = useState<number | null>(null);
20
+ const end = () => { setDragging(null); setOver(null); };
21
+
22
+ return (
23
+ <Stack gap={4} role="listbox" aria-label={label}>
24
+ {items.map((item, i) => {
25
+ const isSelected = i === selected;
26
+ // A line on the side the row will land: below when moving down.
27
+ const dropLine = over === i && dragging !== null && dragging !== i
28
+ ? (dragging < i ? { borderBottom: "2px solid var(--mantine-primary-color-filled)" } : { borderTop: "2px solid var(--mantine-primary-color-filled)" })
29
+ : {};
30
+ return (
31
+ <UnstyledButton
32
+ key={getKey(item, i)}
33
+ role="option"
34
+ aria-selected={isSelected}
35
+ draggable
36
+ onClick={() => onSelect(i)}
37
+ onDragStart={(e) => { setDragging(i); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(i)); }}
38
+ onDragOver={(e) => { if (dragging === null) return; e.preventDefault(); setOver(i); }}
39
+ onDrop={(e) => { e.preventDefault(); if (dragging !== null && dragging !== i) onMove(dragging, i); end(); }}
40
+ onDragEnd={end}
41
+ style={{
42
+ display: "block", width: "100%", padding: "6px 8px", borderRadius: 6,
43
+ background: isSelected ? "var(--mantine-primary-color-light)" : undefined,
44
+ opacity: dragging === i ? 0.5 : 1,
45
+ ...dropLine,
46
+ }}
47
+ >
48
+ {renderItem(item, i)}
49
+ </UnstyledButton>
50
+ );
51
+ })}
52
+ </Stack>
53
+ );
54
+ }