@miketromba/issy-core 0.5.5 → 0.5.7
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/package.json +1 -1
- package/src/lib/autocomplete.ts +168 -164
- package/src/lib/browser.ts +5 -5
- package/src/lib/formatDate.ts +49 -49
- package/src/lib/index.ts +42 -42
- package/src/lib/issues.ts +336 -332
- package/src/lib/query-parser.ts +70 -70
- package/src/lib/search.ts +295 -288
- package/src/lib/types.ts +34 -34
package/package.json
CHANGED
package/src/lib/autocomplete.ts
CHANGED
|
@@ -9,36 +9,36 @@
|
|
|
9
9
|
* A suggestion for autocomplete
|
|
10
10
|
*/
|
|
11
11
|
export interface Suggestion {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
/** The text to insert when selected */
|
|
13
|
+
text: string
|
|
14
|
+
/** The text to display in the dropdown */
|
|
15
|
+
displayText: string
|
|
16
|
+
/** Optional helper text describing the suggestion */
|
|
17
|
+
description?: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Supported qualifier keys
|
|
22
22
|
*/
|
|
23
23
|
const QUALIFIER_KEYS = [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
'is',
|
|
25
|
+
'priority',
|
|
26
|
+
'scope',
|
|
27
|
+
'type',
|
|
28
|
+
'label',
|
|
29
|
+
'sort'
|
|
30
30
|
] as const
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Valid values for each qualifier
|
|
34
34
|
*/
|
|
35
35
|
const QUALIFIER_VALUES: Record<string, readonly string[]> = {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
is: ['open', 'closed'] as const,
|
|
37
|
+
priority: ['high', 'medium', 'low'] as const,
|
|
38
|
+
scope: ['small', 'medium', 'large'] as const,
|
|
39
|
+
type: ['bug', 'improvement'] as const,
|
|
40
|
+
sort: ['roadmap', 'priority', 'scope', 'created', 'updated', 'id'] as const
|
|
41
|
+
// label values are dynamic and provided via existingLabels parameter
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
@@ -58,185 +58,189 @@ const QUALIFIER_VALUES: Record<string, readonly string[]> = {
|
|
|
58
58
|
* // [{ text: "priority:", displayText: "priority:", description: "Filter by priority" }, ...]
|
|
59
59
|
*/
|
|
60
60
|
export function getQuerySuggestions(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
query: string,
|
|
62
|
+
cursorPosition?: number,
|
|
63
|
+
existingLabels?: string[]
|
|
64
64
|
): Suggestion[] {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
65
|
+
// Default cursor position to end of string
|
|
66
|
+
const cursor = cursorPosition ?? query.length
|
|
67
|
+
|
|
68
|
+
// Get the text up to the cursor
|
|
69
|
+
const textBeforeCursor = query.substring(0, cursor)
|
|
70
|
+
|
|
71
|
+
// Find the current token being typed
|
|
72
|
+
const { currentToken } = findCurrentToken(textBeforeCursor)
|
|
73
|
+
|
|
74
|
+
// If we're in the middle of a qualifier (key:value)
|
|
75
|
+
if (currentToken.includes(':')) {
|
|
76
|
+
const colonIndex = currentToken.indexOf(':')
|
|
77
|
+
const qualifierKey = currentToken.substring(0, colonIndex)
|
|
78
|
+
const partialValue = currentToken.substring(colonIndex + 1)
|
|
79
|
+
|
|
80
|
+
// Check if it's a supported qualifier
|
|
81
|
+
if ((QUALIFIER_KEYS as readonly string[]).includes(qualifierKey)) {
|
|
82
|
+
return getValueSuggestions(
|
|
83
|
+
qualifierKey,
|
|
84
|
+
partialValue,
|
|
85
|
+
existingLabels
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if we're typing a qualifier key (with or without colon)
|
|
91
|
+
const partialQualifier = currentToken
|
|
92
|
+
if (partialQualifier && !partialQualifier.includes(':')) {
|
|
93
|
+
const matchingKeys = QUALIFIER_KEYS.filter(key =>
|
|
94
|
+
key.startsWith(partialQualifier.toLowerCase())
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if (matchingKeys.length > 0) {
|
|
98
|
+
return matchingKeys.map(key => ({
|
|
99
|
+
text: `${key}:`,
|
|
100
|
+
displayText: `${key}:`,
|
|
101
|
+
description: getQualifierDescription(key)
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// If we're at the start of a new token (space or start of string)
|
|
107
|
+
// and the previous token doesn't end with a colon, suggest qualifier keys
|
|
108
|
+
if (currentToken === '' || currentToken.trim() === '') {
|
|
109
|
+
const previousToken = getPreviousToken(textBeforeCursor)
|
|
110
|
+
if (!previousToken || !previousToken.endsWith(':')) {
|
|
111
|
+
return QUALIFIER_KEYS.map(key => ({
|
|
112
|
+
text: `${key}:`,
|
|
113
|
+
displayText: `${key}:`,
|
|
114
|
+
description: getQualifierDescription(key)
|
|
115
|
+
}))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return []
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
/**
|
|
119
123
|
* Get suggestions for a qualifier value
|
|
120
124
|
*/
|
|
121
125
|
function getValueSuggestions(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
qualifierKey: string,
|
|
127
|
+
partialValue: string,
|
|
128
|
+
existingLabels?: string[]
|
|
125
129
|
): Suggestion[] {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
130
|
+
const _suggestions: Suggestion[] = []
|
|
131
|
+
|
|
132
|
+
if (qualifierKey === 'label') {
|
|
133
|
+
// For labels, use existing labels if provided
|
|
134
|
+
if (existingLabels && existingLabels.length > 0) {
|
|
135
|
+
const matchingLabels = existingLabels
|
|
136
|
+
.filter(label =>
|
|
137
|
+
label.toLowerCase().includes(partialValue.toLowerCase())
|
|
138
|
+
)
|
|
139
|
+
.slice(0, 10) // Limit to 10 suggestions
|
|
140
|
+
|
|
141
|
+
return matchingLabels.map(label => ({
|
|
142
|
+
text: label,
|
|
143
|
+
displayText: label,
|
|
144
|
+
description: 'Label'
|
|
145
|
+
}))
|
|
146
|
+
}
|
|
147
|
+
return []
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// For other qualifiers, use predefined values
|
|
151
|
+
const validValues = QUALIFIER_VALUES[qualifierKey]
|
|
152
|
+
if (!validValues) {
|
|
153
|
+
return []
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const matchingValues = validValues.filter(value =>
|
|
157
|
+
value.toLowerCase().startsWith(partialValue.toLowerCase())
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return matchingValues.map(value => ({
|
|
161
|
+
text: value,
|
|
162
|
+
displayText: value,
|
|
163
|
+
description: getValueDescription(qualifierKey, value)
|
|
164
|
+
}))
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
/**
|
|
164
168
|
* Find the current token being typed at the cursor position
|
|
165
169
|
*/
|
|
166
170
|
function findCurrentToken(text: string): {
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
currentToken: string
|
|
172
|
+
tokenStart: number
|
|
169
173
|
} {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
174
|
+
if (!text) {
|
|
175
|
+
return { currentToken: '', tokenStart: 0 }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Find the start of the current token (last space or start of string)
|
|
179
|
+
let tokenStart = text.length
|
|
180
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
181
|
+
if (text[i] === ' ') {
|
|
182
|
+
tokenStart = i + 1
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
if (i === 0) {
|
|
186
|
+
tokenStart = 0
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const currentToken = text.substring(tokenStart)
|
|
191
|
+
return { currentToken, tokenStart }
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
/**
|
|
191
195
|
* Get the previous token before the cursor
|
|
192
196
|
*/
|
|
193
197
|
function getPreviousToken(text: string): string | null {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
198
|
+
if (!text || text.trim() === '') {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
+
const tokens = text.trim().split(/\s+/)
|
|
203
|
+
if (tokens.length < 2) {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
202
206
|
|
|
203
|
-
|
|
204
|
-
|
|
207
|
+
// Get the second-to-last token
|
|
208
|
+
return tokens[tokens.length - 2]
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
/**
|
|
208
212
|
* Get a human-readable description for a qualifier key
|
|
209
213
|
*/
|
|
210
214
|
function getQualifierDescription(key: string): string {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
215
|
+
const descriptions: Record<string, string> = {
|
|
216
|
+
is: 'Filter by status',
|
|
217
|
+
priority: 'Filter by priority',
|
|
218
|
+
scope: 'Filter by scope',
|
|
219
|
+
type: 'Filter by type',
|
|
220
|
+
label: 'Filter by label',
|
|
221
|
+
sort: 'Sort results'
|
|
222
|
+
}
|
|
223
|
+
return descriptions[key] || ''
|
|
220
224
|
}
|
|
221
225
|
|
|
222
226
|
/**
|
|
223
227
|
* Get a human-readable description for a qualifier value
|
|
224
228
|
*/
|
|
225
229
|
function getValueDescription(key: string, value: string): string {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
230
|
+
if (key === 'is') {
|
|
231
|
+
return value === 'open' ? 'Open issues' : 'Closed issues'
|
|
232
|
+
}
|
|
233
|
+
if (key === 'priority') {
|
|
234
|
+
return `Priority: ${value}`
|
|
235
|
+
}
|
|
236
|
+
if (key === 'scope') {
|
|
237
|
+
return `Scope: ${value}`
|
|
238
|
+
}
|
|
239
|
+
if (key === 'type') {
|
|
240
|
+
return value === 'bug' ? 'Bug report' : 'Improvement'
|
|
241
|
+
}
|
|
242
|
+
if (key === 'sort') {
|
|
243
|
+
return `Sort by ${value}`
|
|
244
|
+
}
|
|
245
|
+
return ''
|
|
242
246
|
}
|
package/src/lib/browser.ts
CHANGED
|
@@ -15,9 +15,9 @@ export { filterByQuery, filterIssues } from './search'
|
|
|
15
15
|
|
|
16
16
|
// Types
|
|
17
17
|
export type {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
CreateIssueInput,
|
|
19
|
+
Issue,
|
|
20
|
+
IssueFilters,
|
|
21
|
+
IssueFrontmatter,
|
|
22
|
+
UpdateIssueInput
|
|
23
23
|
} from './types'
|
package/src/lib/formatDate.ts
CHANGED
|
@@ -5,59 +5,59 @@ import { format, formatDistanceToNow, isValid, parseISO } from 'date-fns'
|
|
|
5
5
|
* Shows relative time for recent dates, full date for older ones
|
|
6
6
|
*/
|
|
7
7
|
export function formatDisplayDate(dateStr: string | undefined): string {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
8
|
+
if (!dateStr) return ''
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
// Handle both YYYY-MM-DD and ISO formats
|
|
12
|
+
const date = dateStr.includes('T')
|
|
13
|
+
? parseISO(dateStr)
|
|
14
|
+
: parseISO(`${dateStr}T00:00:00`)
|
|
15
|
+
|
|
16
|
+
if (!isValid(date)) return dateStr
|
|
17
|
+
|
|
18
|
+
const now = new Date()
|
|
19
|
+
const diffInDays = Math.floor(
|
|
20
|
+
(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if (diffInDays < 7) {
|
|
24
|
+
// Within last week: "2 days ago", "3 hours ago"
|
|
25
|
+
// Remove "about" prefix for cleaner output
|
|
26
|
+
return formatDistanceToNow(date, { addSuffix: true }).replace(
|
|
27
|
+
/^about /,
|
|
28
|
+
''
|
|
29
|
+
)
|
|
30
|
+
} else if (diffInDays < 365) {
|
|
31
|
+
// Within last year: "Jan 15"
|
|
32
|
+
return format(date, 'MMM d')
|
|
33
|
+
} else {
|
|
34
|
+
// Older: "Jan 15, 2024"
|
|
35
|
+
return format(date, 'MMM d, yyyy')
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
return dateStr
|
|
39
|
+
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Format a date string for tooltip (full date and time)
|
|
44
44
|
*/
|
|
45
45
|
export function formatFullDate(dateStr: string | undefined): string {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
46
|
+
if (!dateStr) return ''
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const date = dateStr.includes('T')
|
|
50
|
+
? parseISO(dateStr)
|
|
51
|
+
: parseISO(`${dateStr}T00:00:00`)
|
|
52
|
+
|
|
53
|
+
if (!isValid(date)) return dateStr
|
|
54
|
+
|
|
55
|
+
// If it has time info, show it
|
|
56
|
+
if (dateStr.includes('T')) {
|
|
57
|
+
return format(date, "MMM d, yyyy 'at' h:mm a")
|
|
58
|
+
}
|
|
59
|
+
return format(date, 'MMM d, yyyy')
|
|
60
|
+
} catch {
|
|
61
|
+
return dateStr
|
|
62
|
+
}
|
|
63
63
|
}
|
package/src/lib/index.ts
CHANGED
|
@@ -12,55 +12,55 @@ export { getQuerySuggestions } from './autocomplete'
|
|
|
12
12
|
export { formatDisplayDate, formatFullDate } from './formatDate'
|
|
13
13
|
// Core issue operations
|
|
14
14
|
export {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
15
|
+
autoDetectIssuesDir,
|
|
16
|
+
closeIssue,
|
|
17
|
+
computeOrderKey,
|
|
18
|
+
createIssue,
|
|
19
|
+
createSlug,
|
|
20
|
+
deleteIssue,
|
|
21
|
+
ensureIssuesDir,
|
|
22
|
+
findGitRoot,
|
|
23
|
+
findIssuesDirUpward,
|
|
24
|
+
findIssyDirUpward,
|
|
25
|
+
findLegacyIssuesDirUpward,
|
|
26
|
+
formatDate,
|
|
27
|
+
generateBatchOrderKeys,
|
|
28
|
+
generateFrontmatter,
|
|
29
|
+
getAllIssues,
|
|
30
|
+
getIssue,
|
|
31
|
+
getIssueFiles,
|
|
32
|
+
getIssueIdFromFilename,
|
|
33
|
+
getIssuesDir,
|
|
34
|
+
getIssyDir,
|
|
35
|
+
getNextIssue,
|
|
36
|
+
getNextIssueNumber,
|
|
37
|
+
getOnCloseContent,
|
|
38
|
+
getOpenIssuesByOrder,
|
|
39
|
+
hasLegacyIssuesDir,
|
|
40
|
+
parseFrontmatter,
|
|
41
|
+
reopenIssue,
|
|
42
|
+
resolveIssuesDir,
|
|
43
|
+
resolveIssyDir,
|
|
44
|
+
setIssuesDir,
|
|
45
|
+
setIssyDir,
|
|
46
|
+
updateIssue
|
|
47
47
|
} from './issues'
|
|
48
48
|
// Query parser
|
|
49
49
|
export type { ParsedQuery } from './query-parser'
|
|
50
50
|
export { parseQuery } from './query-parser'
|
|
51
51
|
// Search functionality
|
|
52
52
|
export {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
createSearchIndex,
|
|
54
|
+
filterAndSearchIssues,
|
|
55
|
+
filterByQuery,
|
|
56
|
+
filterIssues,
|
|
57
|
+
searchIssues
|
|
58
58
|
} from './search'
|
|
59
59
|
// Types
|
|
60
60
|
export type {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
CreateIssueInput,
|
|
62
|
+
Issue,
|
|
63
|
+
IssueFilters,
|
|
64
|
+
IssueFrontmatter,
|
|
65
|
+
UpdateIssueInput
|
|
66
66
|
} from './types'
|