@miketromba/issy-core 0.5.6 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miketromba/issy-core",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "Issue storage, search, and parsing for issy",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,36 +9,36 @@
9
9
  * A suggestion for autocomplete
10
10
  */
11
11
  export interface Suggestion {
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
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
- 'is',
25
- 'priority',
26
- 'scope',
27
- 'type',
28
- 'label',
29
- 'sort',
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
- 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
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
- query: string,
62
- cursorPosition?: number,
63
- existingLabels?: string[],
61
+ query: string,
62
+ cursorPosition?: number,
63
+ existingLabels?: string[]
64
64
  ): Suggestion[] {
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(qualifierKey, partialValue, existingLabels)
83
- }
84
- }
85
-
86
- // Check if we're typing a qualifier key (with or without colon)
87
- const partialQualifier = currentToken
88
- if (partialQualifier && !partialQualifier.includes(':')) {
89
- const matchingKeys = QUALIFIER_KEYS.filter((key) =>
90
- key.startsWith(partialQualifier.toLowerCase()),
91
- )
92
-
93
- if (matchingKeys.length > 0) {
94
- return matchingKeys.map((key) => ({
95
- text: `${key}:`,
96
- displayText: `${key}:`,
97
- description: getQualifierDescription(key),
98
- }))
99
- }
100
- }
101
-
102
- // If we're at the start of a new token (space or start of string)
103
- // and the previous token doesn't end with a colon, suggest qualifier keys
104
- if (currentToken === '' || currentToken.trim() === '') {
105
- const previousToken = getPreviousToken(textBeforeCursor)
106
- if (!previousToken || !previousToken.endsWith(':')) {
107
- return QUALIFIER_KEYS.map((key) => ({
108
- text: `${key}:`,
109
- displayText: `${key}:`,
110
- description: getQualifierDescription(key),
111
- }))
112
- }
113
- }
114
-
115
- return []
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
- qualifierKey: string,
123
- partialValue: string,
124
- existingLabels?: string[],
126
+ qualifierKey: string,
127
+ partialValue: string,
128
+ existingLabels?: string[]
125
129
  ): Suggestion[] {
126
- const _suggestions: Suggestion[] = []
127
-
128
- if (qualifierKey === 'label') {
129
- // For labels, use existing labels if provided
130
- if (existingLabels && existingLabels.length > 0) {
131
- const matchingLabels = existingLabels
132
- .filter((label) =>
133
- label.toLowerCase().includes(partialValue.toLowerCase()),
134
- )
135
- .slice(0, 10) // Limit to 10 suggestions
136
-
137
- return matchingLabels.map((label) => ({
138
- text: label,
139
- displayText: label,
140
- description: 'Label',
141
- }))
142
- }
143
- return []
144
- }
145
-
146
- // For other qualifiers, use predefined values
147
- const validValues = QUALIFIER_VALUES[qualifierKey]
148
- if (!validValues) {
149
- return []
150
- }
151
-
152
- const matchingValues = validValues.filter((value) =>
153
- value.toLowerCase().startsWith(partialValue.toLowerCase()),
154
- )
155
-
156
- return matchingValues.map((value) => ({
157
- text: value,
158
- displayText: value,
159
- description: getValueDescription(qualifierKey, value),
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
- currentToken: string
168
- tokenStart: number
171
+ currentToken: string
172
+ tokenStart: number
169
173
  } {
170
- if (!text) {
171
- return { currentToken: '', tokenStart: 0 }
172
- }
173
-
174
- // Find the start of the current token (last space or start of string)
175
- let tokenStart = text.length
176
- for (let i = text.length - 1; i >= 0; i--) {
177
- if (text[i] === ' ') {
178
- tokenStart = i + 1
179
- break
180
- }
181
- if (i === 0) {
182
- tokenStart = 0
183
- }
184
- }
185
-
186
- const currentToken = text.substring(tokenStart)
187
- return { currentToken, tokenStart }
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
- if (!text || text.trim() === '') {
195
- return null
196
- }
198
+ if (!text || text.trim() === '') {
199
+ return null
200
+ }
197
201
 
198
- const tokens = text.trim().split(/\s+/)
199
- if (tokens.length < 2) {
200
- return null
201
- }
202
+ const tokens = text.trim().split(/\s+/)
203
+ if (tokens.length < 2) {
204
+ return null
205
+ }
202
206
 
203
- // Get the second-to-last token
204
- return tokens[tokens.length - 2]
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
- const descriptions: Record<string, string> = {
212
- is: 'Filter by status',
213
- priority: 'Filter by priority',
214
- scope: 'Filter by scope',
215
- type: 'Filter by type',
216
- label: 'Filter by label',
217
- sort: 'Sort results',
218
- }
219
- return descriptions[key] || ''
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
- if (key === 'is') {
227
- return value === 'open' ? 'Open issues' : 'Closed issues'
228
- }
229
- if (key === 'priority') {
230
- return `Priority: ${value}`
231
- }
232
- if (key === 'scope') {
233
- return `Scope: ${value}`
234
- }
235
- if (key === 'type') {
236
- return value === 'bug' ? 'Bug report' : 'Improvement'
237
- }
238
- if (key === 'sort') {
239
- return `Sort by ${value}`
240
- }
241
- return ''
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
  }
@@ -15,9 +15,9 @@ export { filterByQuery, filterIssues } from './search'
15
15
 
16
16
  // Types
17
17
  export type {
18
- CreateIssueInput,
19
- Issue,
20
- IssueFilters,
21
- IssueFrontmatter,
22
- UpdateIssueInput,
18
+ CreateIssueInput,
19
+ Issue,
20
+ IssueFilters,
21
+ IssueFrontmatter,
22
+ UpdateIssueInput
23
23
  } from './types'
@@ -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
- 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
- }
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
- 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
- }
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
- 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,
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
- createSearchIndex,
54
- filterAndSearchIssues,
55
- filterByQuery,
56
- filterIssues,
57
- searchIssues,
53
+ createSearchIndex,
54
+ filterAndSearchIssues,
55
+ filterByQuery,
56
+ filterIssues,
57
+ searchIssues
58
58
  } from './search'
59
59
  // Types
60
60
  export type {
61
- CreateIssueInput,
62
- Issue,
63
- IssueFilters,
64
- IssueFrontmatter,
65
- UpdateIssueInput,
61
+ CreateIssueInput,
62
+ Issue,
63
+ IssueFilters,
64
+ IssueFrontmatter,
65
+ UpdateIssueInput
66
66
  } from './types'