@tomradex/domain 0.0.0-stage → 0.2.16
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/LICENSE +204 -0
- package/README.md +2 -2
- package/dist/attachments.d.ts +19 -0
- package/dist/attachments.js +13 -0
- package/dist/delivery.d.ts +73 -0
- package/dist/delivery.js +7 -0
- package/dist/derive.d.ts +8 -0
- package/dist/derive.js +14 -0
- package/dist/errors.d.ts +75 -0
- package/dist/errors.js +155 -0
- package/dist/fields.d.ts +58 -0
- package/dist/fields.js +104 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/language.d.ts +49 -0
- package/dist/language.js +117 -0
- package/dist/query.d.ts +73 -0
- package/dist/query.js +138 -0
- package/dist/schema.d.ts +119 -0
- package/dist/schema.js +56 -0
- package/dist/types.d.ts +311 -0
- package/dist/types.js +8 -0
- package/dist/words.d.ts +39 -0
- package/dist/words.js +45 -0
- package/package.json +26 -4
package/dist/fields.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ITEM_STATUSES } from "./types.js";
|
|
2
|
+
/** The types a user may create. The rest are built-in only. */
|
|
3
|
+
export const CUSTOM_FIELD_TYPES = ["text", "number", "date", "select", "checkbox"];
|
|
4
|
+
/** A checkbox is stored only when ticked; unticked is the absence of a value. */
|
|
5
|
+
export const CHECKED = 1;
|
|
6
|
+
const PRIORITY_NAMES = ["None", "Low", "Medium", "High", "Urgent"];
|
|
7
|
+
const STATUS_NAMES = { backlog: "Backlog", todo: "To Do", doing: "Doing", verifying: "Verifying", done: "Done", cancelled: "Cancelled" };
|
|
8
|
+
const staticOptions = (ids, name) => ids.map((id, rank) => ({ id, name: name(id), description: null, rank }));
|
|
9
|
+
const builtin = (id, name, type, rank, extra = {}) => ({
|
|
10
|
+
id,
|
|
11
|
+
projectId: null,
|
|
12
|
+
name,
|
|
13
|
+
type,
|
|
14
|
+
builtin: true,
|
|
15
|
+
options: extra.options ?? [],
|
|
16
|
+
optionsFrom: extra.optionsFrom,
|
|
17
|
+
readonly: extra.readonly ?? false,
|
|
18
|
+
can: extra.can ?? { filter: true, sort: true, group: type === "select" || type === "item" },
|
|
19
|
+
rank
|
|
20
|
+
});
|
|
21
|
+
/** The fields every project has, in display order. Ids are reserved: a custom field can never claim one. */
|
|
22
|
+
export const BUILTIN_FIELDS = [
|
|
23
|
+
builtin("title", "Title", "text", 0, { can: { filter: true, sort: true, group: false } }),
|
|
24
|
+
builtin("status", "Status", "select", 1, { options: staticOptions(ITEM_STATUSES, (s) => STATUS_NAMES[s]) }),
|
|
25
|
+
builtin("workType", "Work type", "select", 9, { options: staticOptions(["coding", "general"], (s) => s === "coding" ? "Coding" : "General") }),
|
|
26
|
+
// Urgent first: the column order a priority board reads in.
|
|
27
|
+
builtin("priority", "Priority", "select", 2, { options: staticOptions(["4", "3", "2", "1", "0"], (p) => PRIORITY_NAMES[Number(p)]) }),
|
|
28
|
+
builtin("epic", "Epic", "item", 3, { optionsFrom: "epics" }),
|
|
29
|
+
builtin("labels", "Labels", "labels", 4, { can: { filter: true, sort: false, group: false } }),
|
|
30
|
+
builtin("board", "Workstream", "select", 5, { optionsFrom: "boards" }),
|
|
31
|
+
builtin("updated", "Updated", "date", 6, { readonly: true, can: { filter: true, sort: true, group: false } }),
|
|
32
|
+
builtin("key", "Key", "number", 7, { readonly: true, can: { filter: false, sort: true, group: false } }),
|
|
33
|
+
builtin("provenance", "Last write", "select", 8, {
|
|
34
|
+
readonly: true,
|
|
35
|
+
options: staticOptions(["human", "ai", "sync", "locked"], (p) => ({ human: "human", ai: "ai", sync: "sync", locked: "human-locked" })[p])
|
|
36
|
+
})
|
|
37
|
+
];
|
|
38
|
+
export const BUILTIN_FIELD_IDS = new Set(BUILTIN_FIELDS.map((f) => f.id));
|
|
39
|
+
export const isBuiltinFieldId = (id) => BUILTIN_FIELD_IDS.has(id);
|
|
40
|
+
/** The empty-value sentinel a group key uses, and the literal a filter may name. */
|
|
41
|
+
export const EMPTY_KEY = "none";
|
|
42
|
+
const provenanceOf = (item) => item.humanLocked ? "locked" : item.updatedBy.startsWith("ai:") ? "ai" : item.updatedBy.startsWith("sync:") ? "sync" : "human";
|
|
43
|
+
/**
|
|
44
|
+
* The raw value of a field on an item, built-in or custom. Labels are the one
|
|
45
|
+
* multi-valued field; everything else is a scalar or null.
|
|
46
|
+
*/
|
|
47
|
+
export function fieldValueOf(item, fieldId) {
|
|
48
|
+
switch (fieldId) {
|
|
49
|
+
case "title":
|
|
50
|
+
return item.title;
|
|
51
|
+
case "status":
|
|
52
|
+
return item.status;
|
|
53
|
+
case "workType":
|
|
54
|
+
return item.workType;
|
|
55
|
+
case "priority":
|
|
56
|
+
return String(item.priority);
|
|
57
|
+
case "epic":
|
|
58
|
+
return item.parentId;
|
|
59
|
+
case "labels":
|
|
60
|
+
return item.labels;
|
|
61
|
+
case "board":
|
|
62
|
+
return item.boardId;
|
|
63
|
+
case "updated":
|
|
64
|
+
return item.updatedAt;
|
|
65
|
+
case "key":
|
|
66
|
+
return item.keyNum;
|
|
67
|
+
case "provenance":
|
|
68
|
+
return provenanceOf(item);
|
|
69
|
+
default:
|
|
70
|
+
return item.fields[fieldId] ?? null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Which column or swimlane an item falls into for a groupable field. */
|
|
74
|
+
export function groupKeyOf(item, fieldId) {
|
|
75
|
+
const v = fieldValueOf(item, fieldId);
|
|
76
|
+
if (v === null || Array.isArray(v))
|
|
77
|
+
return EMPTY_KEY;
|
|
78
|
+
return String(v);
|
|
79
|
+
}
|
|
80
|
+
/** The patch a drop into `key` writes, for a groupable field; null when the field cannot be written. */
|
|
81
|
+
export function patchForGroup(fieldId, key, def) {
|
|
82
|
+
const empty = key === EMPTY_KEY;
|
|
83
|
+
switch (fieldId) {
|
|
84
|
+
case "status":
|
|
85
|
+
return empty ? null : { status: key };
|
|
86
|
+
case "workType":
|
|
87
|
+
return key === "coding" || key === "general" ? { workType: key } : null;
|
|
88
|
+
case "priority":
|
|
89
|
+
return empty ? null : { priority: Number(key) };
|
|
90
|
+
case "epic":
|
|
91
|
+
return { parentId: empty ? null : key };
|
|
92
|
+
case "board":
|
|
93
|
+
return empty ? null : { boardId: key };
|
|
94
|
+
default:
|
|
95
|
+
if (!def || def.builtin)
|
|
96
|
+
return null;
|
|
97
|
+
if (def.type === "select")
|
|
98
|
+
return { fields: { [fieldId]: empty ? null : key } };
|
|
99
|
+
if (def.type === "checkbox")
|
|
100
|
+
return { fields: { [fieldId]: empty ? null : CHECKED } };
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=fields.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./delivery.js";
|
|
3
|
+
export * from "./attachments.js";
|
|
4
|
+
export * from "./errors.js";
|
|
5
|
+
export * from "./schema.js";
|
|
6
|
+
export * from "./derive.js";
|
|
7
|
+
export * from "./fields.js";
|
|
8
|
+
export * from "./query.js";
|
|
9
|
+
export * from "./words.js";
|
|
10
|
+
export * from "./language.js";
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./delivery.js";
|
|
3
|
+
export * from "./attachments.js";
|
|
4
|
+
export * from "./errors.js";
|
|
5
|
+
export * from "./schema.js";
|
|
6
|
+
export * from "./derive.js";
|
|
7
|
+
export * from "./fields.js";
|
|
8
|
+
export * from "./query.js";
|
|
9
|
+
export * from "./words.js";
|
|
10
|
+
export * from "./language.js";
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Languages as data. English is the source every dictionary translates: a
|
|
3
|
+
* translation is a set of `namespace → key → template` entries, and an entry
|
|
4
|
+
* counts only where English has the key and the template asks for the same
|
|
5
|
+
* values. Everything here is pure, so the main process and the renderer
|
|
6
|
+
* merge, check and pick languages the same way.
|
|
7
|
+
*/
|
|
8
|
+
/** A BCP 47 language tag: "en", "zh-Hant", "pt-BR". */
|
|
9
|
+
export type LanguageTag = string;
|
|
10
|
+
/** Templates by namespace, then key. */
|
|
11
|
+
export type Dictionaries = Record<string, Record<string, string>>;
|
|
12
|
+
/** The tag in its canonical spelling ("pt-br" → "pt-BR"), or null when it is not a language tag. */
|
|
13
|
+
export declare function canonicalTag(tag: unknown): LanguageTag | null;
|
|
14
|
+
export declare const isLanguageTag: (tag: unknown) => tag is LanguageTag;
|
|
15
|
+
/** Any language a person may be reading in; English for anything that is not a tag ("system", nothing at all). */
|
|
16
|
+
export declare const languageTag: (locale: unknown) => LanguageTag;
|
|
17
|
+
/** The language's English name, for a prompt's "reply in …" line: "Brazilian Portuguese". */
|
|
18
|
+
export declare function languageName(locale: unknown): string;
|
|
19
|
+
/** What a template fills in or keys off: its `{{slot}}`s and the `[[cmd:…]]` chords the interface writes. */
|
|
20
|
+
export declare const slotsOf: (template: string) => string[];
|
|
21
|
+
export declare const sameSlots: (a: string, b: string) => boolean;
|
|
22
|
+
/** The entries of a translation that can stand in for English: keys English has, asking for the values its template does. */
|
|
23
|
+
export declare function usableWords(english: Dictionaries, translated: Dictionaries): Dictionaries;
|
|
24
|
+
export interface LanguageCoverage {
|
|
25
|
+
/** Entries English has. */
|
|
26
|
+
total: number;
|
|
27
|
+
/** Entries translated with the slots English asks for. */
|
|
28
|
+
translated: number;
|
|
29
|
+
/** `namespace.key` English has and the translation does not; these read in English. */
|
|
30
|
+
missing: string[];
|
|
31
|
+
/** `namespace.key` the translation has and English no longer does; nothing reads them. */
|
|
32
|
+
stale: string[];
|
|
33
|
+
/** Entries whose template asks for other values than English's; these read in English. */
|
|
34
|
+
slotMismatches: {
|
|
35
|
+
key: string;
|
|
36
|
+
english: string[];
|
|
37
|
+
translated: string[];
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
40
|
+
/** How much of English a translation covers, and what in it nothing can use. */
|
|
41
|
+
export declare function languageCoverage(english: Dictionaries, translated: Dictionaries): LanguageCoverage;
|
|
42
|
+
/**
|
|
43
|
+
* The language to show for a system that prefers `system`, among those the
|
|
44
|
+
* app has: the same tag, else the same language written in the same script —
|
|
45
|
+
* the system's region first, then the language's plain tag. zh-TW finds
|
|
46
|
+
* zh-Hant and never zh; pt-PT finds pt-BR when that is all there is.
|
|
47
|
+
*/
|
|
48
|
+
export declare function pickLanguage(system: string | readonly string[] | undefined | null, available: readonly LanguageTag[]): LanguageTag | null;
|
|
49
|
+
//# sourceMappingURL=language.d.ts.map
|
package/dist/language.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Languages as data. English is the source every dictionary translates: a
|
|
3
|
+
* translation is a set of `namespace → key → template` entries, and an entry
|
|
4
|
+
* counts only where English has the key and the template asks for the same
|
|
5
|
+
* values. Everything here is pure, so the main process and the renderer
|
|
6
|
+
* merge, check and pick languages the same way.
|
|
7
|
+
*/
|
|
8
|
+
const canonical = new Map();
|
|
9
|
+
/** The tag in its canonical spelling ("pt-br" → "pt-BR"), or null when it is not a language tag. */
|
|
10
|
+
export function canonicalTag(tag) {
|
|
11
|
+
if (typeof tag !== "string" || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/.test(tag))
|
|
12
|
+
return null;
|
|
13
|
+
let known = canonical.get(tag);
|
|
14
|
+
if (known === undefined) {
|
|
15
|
+
try {
|
|
16
|
+
known = Intl.getCanonicalLocales(tag)[0] ?? null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
known = null;
|
|
20
|
+
}
|
|
21
|
+
canonical.set(tag, known);
|
|
22
|
+
}
|
|
23
|
+
return known;
|
|
24
|
+
}
|
|
25
|
+
export const isLanguageTag = (tag) => canonicalTag(tag) !== null;
|
|
26
|
+
/** Any language a person may be reading in; English for anything that is not a tag ("system", nothing at all). */
|
|
27
|
+
export const languageTag = (locale) => canonicalTag(locale) ?? "en";
|
|
28
|
+
// Intl names zh "Chinese"; the app's zh is the simplified script.
|
|
29
|
+
const NAMES = { zh: "Simplified Chinese", "zh-Hant": "Traditional Chinese" };
|
|
30
|
+
/** The language's English name, for a prompt's "reply in …" line: "Brazilian Portuguese". */
|
|
31
|
+
export function languageName(locale) {
|
|
32
|
+
const tag = languageTag(locale);
|
|
33
|
+
if (NAMES[tag])
|
|
34
|
+
return NAMES[tag];
|
|
35
|
+
try {
|
|
36
|
+
return new Intl.DisplayNames(["en"], { type: "language" }).of(tag) ?? tag;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return tag;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** What a template fills in or keys off: its `{{slot}}`s and the `[[cmd:…]]` chords the interface writes. */
|
|
43
|
+
export const slotsOf = (template) => [...new Set((template.match(/\{\{\s*\w+\s*\}\}|\[\[cmd:\w+\]\]/g) ?? []).map((s) => s.replace(/\s/g, "")))].sort();
|
|
44
|
+
export const sameSlots = (a, b) => slotsOf(a).join("\0") === slotsOf(b).join("\0");
|
|
45
|
+
/** The entries of a translation that can stand in for English: keys English has, asking for the values its template does. */
|
|
46
|
+
export function usableWords(english, translated) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [ns, entries] of Object.entries(translated)) {
|
|
49
|
+
const source = english[ns];
|
|
50
|
+
if (!source || typeof entries !== "object" || entries === null)
|
|
51
|
+
continue;
|
|
52
|
+
for (const [key, text] of Object.entries(entries)) {
|
|
53
|
+
if (typeof text !== "string" || typeof source[key] !== "string" || !sameSlots(source[key], text))
|
|
54
|
+
continue;
|
|
55
|
+
(out[ns] ??= {})[key] = text;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** How much of English a translation covers, and what in it nothing can use. */
|
|
61
|
+
export function languageCoverage(english, translated) {
|
|
62
|
+
const out = { total: 0, translated: 0, missing: [], stale: [], slotMismatches: [] };
|
|
63
|
+
for (const [ns, entries] of Object.entries(english)) {
|
|
64
|
+
for (const [key, source] of Object.entries(entries)) {
|
|
65
|
+
out.total += 1;
|
|
66
|
+
const text = translated[ns]?.[key];
|
|
67
|
+
if (typeof text !== "string")
|
|
68
|
+
out.missing.push(`${ns}.${key}`);
|
|
69
|
+
else if (!sameSlots(source, text))
|
|
70
|
+
out.slotMismatches.push({ key: `${ns}.${key}`, english: slotsOf(source), translated: slotsOf(text) });
|
|
71
|
+
else
|
|
72
|
+
out.translated += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const [ns, entries] of Object.entries(translated)) {
|
|
76
|
+
for (const key of Object.keys(entries ?? {}))
|
|
77
|
+
if (typeof english[ns]?.[key] !== "string")
|
|
78
|
+
out.stale.push(`${ns}.${key}`);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
const parts = (tag) => {
|
|
83
|
+
try {
|
|
84
|
+
const l = new Intl.Locale(tag).maximize();
|
|
85
|
+
return { language: l.language, script: l.script, region: l.region };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The language to show for a system that prefers `system`, among those the
|
|
93
|
+
* app has: the same tag, else the same language written in the same script —
|
|
94
|
+
* the system's region first, then the language's plain tag. zh-TW finds
|
|
95
|
+
* zh-Hant and never zh; pt-PT finds pt-BR when that is all there is.
|
|
96
|
+
*/
|
|
97
|
+
export function pickLanguage(system, available) {
|
|
98
|
+
const wanted = (typeof system === "string" ? [system] : (system ?? [])).map((s) => canonicalTag(s.replace(/_/g, "-").split(".")[0])).filter((s) => !!s);
|
|
99
|
+
const options = available.flatMap((tag) => {
|
|
100
|
+
const p = parts(tag);
|
|
101
|
+
return p ? [{ tag, ...p }] : [];
|
|
102
|
+
});
|
|
103
|
+
for (const tag of wanted) {
|
|
104
|
+
const exact = available.find((a) => a.toLowerCase() === tag.toLowerCase());
|
|
105
|
+
if (exact)
|
|
106
|
+
return exact;
|
|
107
|
+
const want = parts(tag);
|
|
108
|
+
if (!want)
|
|
109
|
+
continue;
|
|
110
|
+
const same = options.filter((o) => o.language === want.language && o.script === want.script);
|
|
111
|
+
const pick = same.find((o) => o.region === want.region && o.tag.includes("-")) ?? same.find((o) => o.tag === want.language) ?? same[0];
|
|
112
|
+
if (pick)
|
|
113
|
+
return pick.tag;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=language.js.map
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type FieldDef } from "./fields.js";
|
|
2
|
+
import type { FieldValue, Item } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* How a view draws the backlog. Board and table group and sort; a graph draws
|
|
5
|
+
* the edges a plugin contributes. A view is one layout plus one query.
|
|
6
|
+
*/
|
|
7
|
+
export type Layout = "board" | "table" | "graph";
|
|
8
|
+
export declare const LAYOUTS: readonly Layout[];
|
|
9
|
+
/**
|
|
10
|
+
* `in` / `not-in` take values; a null value means "empty". `contains` is a
|
|
11
|
+
* case-insensitive substring over text. `gte` / `lte` compare numbers and dates.
|
|
12
|
+
*/
|
|
13
|
+
export type FilterOp = "in" | "not-in" | "contains" | "gte" | "lte";
|
|
14
|
+
export interface FilterClause {
|
|
15
|
+
field: string;
|
|
16
|
+
op: FilterOp;
|
|
17
|
+
values: (FieldValue | null)[];
|
|
18
|
+
}
|
|
19
|
+
export interface SortKey {
|
|
20
|
+
field: string;
|
|
21
|
+
dir: "asc" | "desc";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The stored shape of a view's query and the live shape the bar edits. Empty
|
|
25
|
+
* `columns` means every key the field has; empty `sortBy` means manual order.
|
|
26
|
+
*/
|
|
27
|
+
export interface ViewQuery {
|
|
28
|
+
filter: FilterClause[];
|
|
29
|
+
/** Board layout: the field one column stands for. */
|
|
30
|
+
columnBy: string | null;
|
|
31
|
+
/** Visible column keys in order; empty shows them all. */
|
|
32
|
+
columns: string[];
|
|
33
|
+
/** Swimlanes on a board, groups in a table. */
|
|
34
|
+
groupBy: string | null;
|
|
35
|
+
sortBy: SortKey[];
|
|
36
|
+
/** Fields drawn on cards and as table columns; empty means the layout's default. */
|
|
37
|
+
fields: string[];
|
|
38
|
+
/** WIP cap per column key. Advisory — a drop is never blocked. */
|
|
39
|
+
wip: Record<string, number>;
|
|
40
|
+
/** Graph layout: the contributed graph whose edges are drawn. */
|
|
41
|
+
graph: string | null;
|
|
42
|
+
}
|
|
43
|
+
export declare const EMPTY_QUERY: ViewQuery;
|
|
44
|
+
/** Fill a partially stored query so every consumer sees the full shape. */
|
|
45
|
+
export declare const normalizeQuery: (q: Partial<ViewQuery> | null | undefined) => ViewQuery;
|
|
46
|
+
export interface QueryContext {
|
|
47
|
+
defs: Map<string, FieldDef>;
|
|
48
|
+
/** Position of an option in its field's order; used to sort and to order columns. */
|
|
49
|
+
optionRank(fieldId: string, key: string): number;
|
|
50
|
+
/** A display name for an option key, for sorting fields whose options have no rank (epics by title). */
|
|
51
|
+
optionName(fieldId: string, key: string): string;
|
|
52
|
+
}
|
|
53
|
+
/** A `YYYY-MM-DD` bound against an epoch-ms value covers the whole day it names. */
|
|
54
|
+
export declare function dayBound(value: FieldValue | null | undefined, op: "gte" | "lte"): number | null;
|
|
55
|
+
export declare function matchesClause(item: Item, clause: FilterClause): boolean;
|
|
56
|
+
export declare const matchesFilter: (item: Item, filter: FilterClause[]) => boolean;
|
|
57
|
+
/**
|
|
58
|
+
* The terms of a search: whitespace-separated words, except that a run in
|
|
59
|
+
* double quotes ("login timeout", “登录 超时”) is one term matched as a whole,
|
|
60
|
+
* spaces included. A quote with no partner runs to the end of the string.
|
|
61
|
+
*/
|
|
62
|
+
export declare function searchTerms(search: string): string[];
|
|
63
|
+
/** Every term appears, case aside, in the ticket's key, title or body — what the items index matches, plus the key. */
|
|
64
|
+
export declare function matchesSearch(item: Item, terms: string[]): boolean;
|
|
65
|
+
/** A comparable for one sort key: numbers for numeric fields, strings otherwise, null when empty. */
|
|
66
|
+
export declare function sortKeyOf(item: Item, fieldId: string, ctx: QueryContext): number | string | null;
|
|
67
|
+
/** Empty values sort last whichever direction; ties fall through to manual rank, then recency. */
|
|
68
|
+
export declare function compareItems(a: Item, b: Item, sortBy: SortKey[], ctx: QueryContext, rankOf: (item: Item) => number): number;
|
|
69
|
+
/** The keys a groupable field can take, in the field's order, with the empty key last where it applies. */
|
|
70
|
+
export declare function groupSpaceOf(def: FieldDef, options: {
|
|
71
|
+
id: string;
|
|
72
|
+
}[]): string[];
|
|
73
|
+
//# sourceMappingURL=query.d.ts.map
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { CHECKED, EMPTY_KEY, fieldValueOf } from "./fields.js";
|
|
2
|
+
export const LAYOUTS = ["board", "table", "graph"];
|
|
3
|
+
export const EMPTY_QUERY = { filter: [], columnBy: "status", columns: [], groupBy: null, sortBy: [], fields: [], wip: {}, graph: null };
|
|
4
|
+
/** Fill a partially stored query so every consumer sees the full shape. */
|
|
5
|
+
export const normalizeQuery = (q) => ({ ...EMPTY_QUERY, ...(q ?? {}), wip: { ...(q?.wip ?? {}) } });
|
|
6
|
+
const norm = (v) => (v === null ? null : String(v));
|
|
7
|
+
const DAY = 86_400_000;
|
|
8
|
+
/** A `YYYY-MM-DD` bound against an epoch-ms value covers the whole day it names. */
|
|
9
|
+
export function dayBound(value, op) {
|
|
10
|
+
if (value === null || value === undefined)
|
|
11
|
+
return null;
|
|
12
|
+
if (typeof value === "number")
|
|
13
|
+
return value;
|
|
14
|
+
const t = Date.parse(value);
|
|
15
|
+
if (Number.isNaN(t))
|
|
16
|
+
return null;
|
|
17
|
+
return op === "lte" ? t + DAY - 1 : t;
|
|
18
|
+
}
|
|
19
|
+
export function matchesClause(item, clause) {
|
|
20
|
+
const raw = fieldValueOf(item, clause.field);
|
|
21
|
+
const values = Array.isArray(raw) ? raw : raw === null ? [] : [raw];
|
|
22
|
+
switch (clause.op) {
|
|
23
|
+
case "in": {
|
|
24
|
+
const wanted = new Set(clause.values.map(norm));
|
|
25
|
+
if (!values.length)
|
|
26
|
+
return wanted.has(null);
|
|
27
|
+
return values.some((v) => wanted.has(String(v)));
|
|
28
|
+
}
|
|
29
|
+
case "not-in": {
|
|
30
|
+
const wanted = new Set(clause.values.map(norm));
|
|
31
|
+
if (!values.length)
|
|
32
|
+
return !wanted.has(null);
|
|
33
|
+
return !values.some((v) => wanted.has(String(v)));
|
|
34
|
+
}
|
|
35
|
+
case "contains": {
|
|
36
|
+
const needle = String(clause.values[0] ?? "").toLowerCase();
|
|
37
|
+
if (!needle)
|
|
38
|
+
return true;
|
|
39
|
+
return values.some((v) => String(v).toLowerCase().includes(needle));
|
|
40
|
+
}
|
|
41
|
+
case "gte":
|
|
42
|
+
case "lte": {
|
|
43
|
+
if (!values.length)
|
|
44
|
+
return false;
|
|
45
|
+
const v = values[0];
|
|
46
|
+
// A numeric field may be bounded by a date string (Updated); anything else compares like for like.
|
|
47
|
+
const bound = typeof v === "number" ? dayBound(clause.values[0], clause.op) : (clause.values[0] ?? null);
|
|
48
|
+
if (bound === null)
|
|
49
|
+
return false;
|
|
50
|
+
const cmp = typeof v === "number" && typeof bound === "number" ? v - bound : String(v).localeCompare(String(bound));
|
|
51
|
+
return clause.op === "gte" ? cmp >= 0 : cmp <= 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export const matchesFilter = (item, filter) => filter.every((c) => matchesClause(item, c));
|
|
56
|
+
/**
|
|
57
|
+
* The terms of a search: whitespace-separated words, except that a run in
|
|
58
|
+
* double quotes ("login timeout", “登录 超时”) is one term matched as a whole,
|
|
59
|
+
* spaces included. A quote with no partner runs to the end of the string.
|
|
60
|
+
*/
|
|
61
|
+
export function searchTerms(search) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const closer = { '"': '"', "\u201c": "\u201d" };
|
|
64
|
+
let i = 0;
|
|
65
|
+
while (i < search.length) {
|
|
66
|
+
const c = search[i];
|
|
67
|
+
if (/\s/.test(c)) {
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const close = closer[c];
|
|
72
|
+
if (close) {
|
|
73
|
+
const end = search.indexOf(close, i + 1);
|
|
74
|
+
const phrase = (end === -1 ? search.slice(i + 1) : search.slice(i + 1, end)).trim();
|
|
75
|
+
if (phrase)
|
|
76
|
+
out.push(phrase);
|
|
77
|
+
i = end === -1 ? search.length : end + 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let j = i;
|
|
81
|
+
while (j < search.length && !/\s/.test(search[j]))
|
|
82
|
+
j++;
|
|
83
|
+
out.push(search.slice(i, j));
|
|
84
|
+
i = j;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Every term appears, case aside, in the ticket's key, title or body — what the items index matches, plus the key. */
|
|
89
|
+
export function matchesSearch(item, terms) {
|
|
90
|
+
if (!terms.length)
|
|
91
|
+
return true;
|
|
92
|
+
const text = `${item.key} ${item.title} ${item.body ?? ""}`.toLowerCase();
|
|
93
|
+
return terms.every((term) => text.includes(term.toLowerCase()));
|
|
94
|
+
}
|
|
95
|
+
/** A comparable for one sort key: numbers for numeric fields, strings otherwise, null when empty. */
|
|
96
|
+
export function sortKeyOf(item, fieldId, ctx) {
|
|
97
|
+
const raw = fieldValueOf(item, fieldId);
|
|
98
|
+
if (raw === null || Array.isArray(raw))
|
|
99
|
+
return null;
|
|
100
|
+
const def = ctx.defs.get(fieldId);
|
|
101
|
+
if (!def)
|
|
102
|
+
return null;
|
|
103
|
+
if (def.type === "select")
|
|
104
|
+
return ctx.optionRank(fieldId, String(raw));
|
|
105
|
+
if (def.type === "item")
|
|
106
|
+
return ctx.optionName(fieldId, String(raw)).toLowerCase();
|
|
107
|
+
if (def.type === "text")
|
|
108
|
+
return String(raw).toLowerCase();
|
|
109
|
+
return raw;
|
|
110
|
+
}
|
|
111
|
+
/** Empty values sort last whichever direction; ties fall through to manual rank, then recency. */
|
|
112
|
+
export function compareItems(a, b, sortBy, ctx, rankOf) {
|
|
113
|
+
for (const key of sortBy) {
|
|
114
|
+
const ka = sortKeyOf(a, key.field, ctx);
|
|
115
|
+
const kb = sortKeyOf(b, key.field, ctx);
|
|
116
|
+
if (ka === kb)
|
|
117
|
+
continue;
|
|
118
|
+
if (ka === null)
|
|
119
|
+
return 1;
|
|
120
|
+
if (kb === null)
|
|
121
|
+
return -1;
|
|
122
|
+
const cmp = typeof ka === "number" && typeof kb === "number" ? ka - kb : String(ka).localeCompare(String(kb));
|
|
123
|
+
if (cmp !== 0)
|
|
124
|
+
return key.dir === "desc" ? -cmp : cmp;
|
|
125
|
+
}
|
|
126
|
+
return rankOf(a) - rankOf(b) || b.updatedAt - a.updatedAt;
|
|
127
|
+
}
|
|
128
|
+
/** The keys a groupable field can take, in the field's order, with the empty key last where it applies. */
|
|
129
|
+
export function groupSpaceOf(def, options) {
|
|
130
|
+
if (def.type === "checkbox")
|
|
131
|
+
return [String(CHECKED), EMPTY_KEY];
|
|
132
|
+
const keys = options.map((o) => o.id);
|
|
133
|
+
// Total fields: every item has a status, a priority, a provenance and a workstream.
|
|
134
|
+
if (def.id === "status" || def.id === "priority" || def.id === "provenance" || def.id === "board")
|
|
135
|
+
return keys;
|
|
136
|
+
return [...keys, EMPTY_KEY];
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=query.js.map
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const itemKindSchema: z.ZodEnum<{
|
|
3
|
+
epic: "epic";
|
|
4
|
+
ticket: "ticket";
|
|
5
|
+
}>;
|
|
6
|
+
export declare const workTypeSchema: z.ZodEnum<{
|
|
7
|
+
coding: "coding";
|
|
8
|
+
general: "general";
|
|
9
|
+
}>;
|
|
10
|
+
export declare const itemStatusSchema: z.ZodEnum<{
|
|
11
|
+
backlog: "backlog";
|
|
12
|
+
todo: "todo";
|
|
13
|
+
doing: "doing";
|
|
14
|
+
verifying: "verifying";
|
|
15
|
+
done: "done";
|
|
16
|
+
cancelled: "cancelled";
|
|
17
|
+
}>;
|
|
18
|
+
export declare const prioritySchema: z.ZodLiteral<0 | 1 | 2 | 3 | 4>;
|
|
19
|
+
export declare const listItemsFilterSchema: z.ZodObject<{
|
|
20
|
+
projectId: z.ZodOptional<z.ZodString>;
|
|
21
|
+
boardId: z.ZodOptional<z.ZodString>;
|
|
22
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
23
|
+
backlog: "backlog";
|
|
24
|
+
todo: "todo";
|
|
25
|
+
doing: "doing";
|
|
26
|
+
verifying: "verifying";
|
|
27
|
+
done: "done";
|
|
28
|
+
cancelled: "cancelled";
|
|
29
|
+
}>>;
|
|
30
|
+
kind: z.ZodOptional<z.ZodEnum<{
|
|
31
|
+
epic: "epic";
|
|
32
|
+
ticket: "ticket";
|
|
33
|
+
}>>;
|
|
34
|
+
workType: z.ZodOptional<z.ZodEnum<{
|
|
35
|
+
coding: "coding";
|
|
36
|
+
general: "general";
|
|
37
|
+
}>>;
|
|
38
|
+
priority: z.ZodOptional<z.ZodLiteral<0 | 1 | 2 | 3 | 4>>;
|
|
39
|
+
label: z.ZodOptional<z.ZodString>;
|
|
40
|
+
parentId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
41
|
+
search: z.ZodOptional<z.ZodString>;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
export declare const fieldValueSchema: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
|
|
44
|
+
/** A key set to null clears the value. */
|
|
45
|
+
export declare const fieldsPatchSchema: z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>;
|
|
46
|
+
export declare const filterClauseSchema: z.ZodObject<{
|
|
47
|
+
field: z.ZodString;
|
|
48
|
+
op: z.ZodEnum<{
|
|
49
|
+
in: "in";
|
|
50
|
+
"not-in": "not-in";
|
|
51
|
+
contains: "contains";
|
|
52
|
+
gte: "gte";
|
|
53
|
+
lte: "lte";
|
|
54
|
+
}>;
|
|
55
|
+
values: z.ZodArray<z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>;
|
|
56
|
+
}, z.core.$strip>;
|
|
57
|
+
export declare const sortKeySchema: z.ZodObject<{
|
|
58
|
+
field: z.ZodString;
|
|
59
|
+
dir: z.ZodEnum<{
|
|
60
|
+
asc: "asc";
|
|
61
|
+
desc: "desc";
|
|
62
|
+
}>;
|
|
63
|
+
}, z.core.$strip>;
|
|
64
|
+
export declare const createItemSchema: z.ZodObject<{
|
|
65
|
+
playbookId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
66
|
+
title: z.ZodString;
|
|
67
|
+
body: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
68
|
+
kind: z.ZodOptional<z.ZodEnum<{
|
|
69
|
+
epic: "epic";
|
|
70
|
+
ticket: "ticket";
|
|
71
|
+
}>>;
|
|
72
|
+
workType: z.ZodOptional<z.ZodEnum<{
|
|
73
|
+
coding: "coding";
|
|
74
|
+
general: "general";
|
|
75
|
+
}>>;
|
|
76
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
77
|
+
backlog: "backlog";
|
|
78
|
+
todo: "todo";
|
|
79
|
+
doing: "doing";
|
|
80
|
+
verifying: "verifying";
|
|
81
|
+
done: "done";
|
|
82
|
+
cancelled: "cancelled";
|
|
83
|
+
}>>;
|
|
84
|
+
priority: z.ZodOptional<z.ZodLiteral<0 | 1 | 2 | 3 | 4>>;
|
|
85
|
+
labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
86
|
+
parentId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
87
|
+
projectId: z.ZodOptional<z.ZodString>;
|
|
88
|
+
boardId: z.ZodOptional<z.ZodString>;
|
|
89
|
+
fields: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>>;
|
|
90
|
+
}, z.core.$strip>;
|
|
91
|
+
export declare const patchSchema: z.ZodObject<{
|
|
92
|
+
playbookId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
93
|
+
title: z.ZodOptional<z.ZodString>;
|
|
94
|
+
body: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
95
|
+
kind: z.ZodOptional<z.ZodEnum<{
|
|
96
|
+
epic: "epic";
|
|
97
|
+
ticket: "ticket";
|
|
98
|
+
}>>;
|
|
99
|
+
workType: z.ZodOptional<z.ZodEnum<{
|
|
100
|
+
coding: "coding";
|
|
101
|
+
general: "general";
|
|
102
|
+
}>>;
|
|
103
|
+
status: z.ZodOptional<z.ZodEnum<{
|
|
104
|
+
backlog: "backlog";
|
|
105
|
+
todo: "todo";
|
|
106
|
+
doing: "doing";
|
|
107
|
+
verifying: "verifying";
|
|
108
|
+
done: "done";
|
|
109
|
+
cancelled: "cancelled";
|
|
110
|
+
}>>;
|
|
111
|
+
priority: z.ZodOptional<z.ZodLiteral<0 | 1 | 2 | 3 | 4>>;
|
|
112
|
+
labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
113
|
+
blocks: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
114
|
+
parentId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
115
|
+
rank: z.ZodOptional<z.ZodNumber>;
|
|
116
|
+
boardId: z.ZodOptional<z.ZodString>;
|
|
117
|
+
fields: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>>;
|
|
118
|
+
}, z.core.$strip>;
|
|
119
|
+
//# sourceMappingURL=schema.d.ts.map
|