Fortran · 11617 bytes Raw Blame History
1 ! Recents management module
2 ! Handles recents.json read/write and recent workspace tracking
3
4 module recents_module
5 use iso_fortran_env, only: int32
6 use config_module, only: get_config_dir, ensure_config_dir
7 implicit none
8 private
9
10 public :: recent_t, recents_load, recents_save, recents_add_or_update
11 public :: recents_exists, recents_get_path, recents_remove
12
13 integer, parameter :: MAX_PATH_LEN = 512
14 integer, parameter :: MAX_LABEL_LEN = 128
15 integer, parameter :: DEFAULT_MAX_RECENTS = 20
16
17 type :: recent_t
18 character(len=MAX_PATH_LEN) :: path = ""
19 character(len=MAX_LABEL_LEN) :: label = ""
20 character(len=32) :: last_opened = "" ! ISO 8601 timestamp
21 integer :: open_count = 0
22 end type recent_t
23
24 contains
25
26 !> Get path to recents.json
27 subroutine recents_get_path(recents_file)
28 character(len=:), allocatable, intent(out) :: recents_file
29 character(len=:), allocatable :: config_dir
30
31 call get_config_dir(config_dir)
32 recents_file = trim(config_dir) // '/recents.json'
33 end subroutine recents_get_path
34
35 !> Check if recents.json exists
36 function recents_exists() result(exists)
37 logical :: exists
38 character(len=:), allocatable :: recents_file
39 integer :: unit, ios
40
41 call recents_get_path(recents_file)
42 open(newunit=unit, file=recents_file, status='old', iostat=ios)
43 exists = (ios == 0)
44 if (exists) close(unit)
45 end function recents_exists
46
47 !> Load recents from recents.json
48 subroutine recents_load(recents, count, max_recents, success)
49 type(recent_t), allocatable, intent(out) :: recents(:)
50 integer, intent(out) :: count, max_recents
51 logical, intent(out) :: success
52 character(len=:), allocatable :: recents_file
53 character(len=1024) :: line
54 integer :: unit, ios, i
55 logical :: in_recents, reading_entry
56
57 success = .false.
58 count = 0
59 max_recents = DEFAULT_MAX_RECENTS
60 allocate(recents(max_recents))
61
62 ! Ensure config directory exists
63 call ensure_config_dir(success)
64 if (.not. success) return
65
66 call recents_get_path(recents_file)
67
68 ! Open file
69 open(newunit=unit, file=recents_file, status='old', iostat=ios)
70 if (ios /= 0) then
71 ! File doesn't exist - return empty list
72 success = .true.
73 count = 0
74 return
75 end if
76
77 ! Simple JSON parsing
78 in_recents = .false.
79 reading_entry = .false.
80 i = 0
81
82 do
83 read(unit, '(A)', iostat=ios) line
84 if (ios /= 0) exit
85
86 line = adjustl(line)
87
88 ! Check for max_recents setting
89 if (index(line, '"max_recents"') > 0) then
90 call extract_json_int(line, max_recents)
91 cycle
92 end if
93
94 ! Check if we're in the recents array
95 if (index(line, '"recents"') > 0) then
96 in_recents = .true.
97 cycle
98 end if
99
100 if (.not. in_recents) cycle
101
102 ! Look for start of entry
103 if (index(line, '{') > 0 .and. .not. reading_entry) then
104 reading_entry = .true.
105 i = i + 1
106 if (i > max_recents) exit
107 ! Initialize entry
108 recents(i)%path = ""
109 recents(i)%label = ""
110 recents(i)%last_opened = ""
111 recents(i)%open_count = 0
112 cycle
113 end if
114
115 if (reading_entry) then
116 ! Parse fields
117 if (index(line, '"path"') > 0) then
118 call extract_json_string(line, recents(i)%path)
119 else if (index(line, '"label"') > 0) then
120 call extract_json_string(line, recents(i)%label)
121 else if (index(line, '"last_opened"') > 0) then
122 call extract_json_string(line, recents(i)%last_opened)
123 else if (index(line, '"open_count"') > 0) then
124 call extract_json_int(line, recents(i)%open_count)
125 end if
126
127 ! Check for end of entry
128 if (index(line, '}') > 0) then
129 reading_entry = .false.
130 end if
131 end if
132 end do
133
134 close(unit)
135 count = i
136 success = .true.
137 end subroutine recents_load
138
139 !> Save recents to recents.json
140 subroutine recents_save(recents, count, max_recents, success)
141 type(recent_t), intent(in) :: recents(:)
142 integer, intent(in) :: count, max_recents
143 logical, intent(out) :: success
144 character(len=:), allocatable :: recents_file
145 integer :: unit, ios, i
146
147 success = .false.
148
149 ! Ensure config directory exists
150 call ensure_config_dir(success)
151 if (.not. success) return
152
153 call recents_get_path(recents_file)
154
155 ! Write JSON file
156 open(newunit=unit, file=recents_file, status='replace', iostat=ios)
157 if (ios /= 0) return
158
159 write(unit, '(A)') '{'
160 write(unit, '(A,I0,A)') ' "version": "1.0",'
161 write(unit, '(A,I0,A)') ' "max_recents": ', max_recents, ','
162 write(unit, '(A)') ' "recents": ['
163
164 do i = 1, count
165 write(unit, '(A)') ' {'
166 write(unit, '(A)') ' "path": "' // trim(recents(i)%path) // '",'
167 write(unit, '(A)') ' "label": "' // trim(recents(i)%label) // '",'
168 write(unit, '(A)') ' "last_opened": "' // trim(recents(i)%last_opened) // '",'
169 write(unit, '(A,I0)') ' "open_count": ', recents(i)%open_count
170
171 if (i < count) then
172 write(unit, '(A)') ' },'
173 else
174 write(unit, '(A)') ' }'
175 end if
176 end do
177
178 write(unit, '(A)') ' ]'
179 write(unit, '(A)') '}'
180
181 close(unit)
182 success = .true.
183 end subroutine recents_save
184
185 !> Add or update a recent workspace
186 subroutine recents_add_or_update(path, label, success)
187 character(len=*), intent(in) :: path
188 character(len=*), intent(in) :: label
189 logical, intent(out) :: success
190 type(recent_t), allocatable :: recents(:), sorted(:)
191 integer :: count, max_recents, i, found_index
192 character(len=32) :: timestamp
193
194 success = .false.
195
196 ! Load existing recents
197 call recents_load(recents, count, max_recents, success)
198 if (.not. success) return
199
200 call get_current_timestamp(timestamp)
201
202 ! Check if path already exists
203 found_index = 0
204 do i = 1, count
205 if (trim(recents(i)%path) == trim(path)) then
206 found_index = i
207 exit
208 end if
209 end do
210
211 if (found_index > 0) then
212 ! Update existing entry
213 recents(found_index)%last_opened = trim(timestamp)
214 recents(found_index)%open_count = recents(found_index)%open_count + 1
215 else
216 ! Add new entry
217 if (count >= max_recents) then
218 ! Remove oldest entry (last in sorted list)
219 count = count - 1
220 end if
221
222 count = count + 1
223 recents(count)%path = trim(path)
224 recents(count)%label = trim(label)
225 recents(count)%last_opened = trim(timestamp)
226 recents(count)%open_count = 1
227 end if
228
229 ! Sort by last_opened (most recent first)
230 allocate(sorted(max_recents))
231 call sort_recents_by_time(recents, count, sorted)
232
233 ! Save
234 call recents_save(sorted, count, max_recents, success)
235 deallocate(sorted)
236 end subroutine recents_add_or_update
237
238 !> Sort recents by last_opened (most recent first)
239 subroutine sort_recents_by_time(recents, count, sorted)
240 type(recent_t), intent(in) :: recents(:)
241 integer, intent(in) :: count
242 type(recent_t), intent(out) :: sorted(:)
243 integer :: i, j, max_idx
244 character(len=32) :: max_time
245 logical :: used(count)
246
247 used = .false.
248
249 ! Simple selection sort
250 do i = 1, count
251 max_time = ""
252 max_idx = 0
253
254 ! Find most recent unused entry
255 do j = 1, count
256 if (.not. used(j)) then
257 if (len_trim(max_time) == 0 .or. recents(j)%last_opened > max_time) then
258 max_time = recents(j)%last_opened
259 max_idx = j
260 end if
261 end if
262 end do
263
264 if (max_idx > 0) then
265 sorted(i) = recents(max_idx)
266 used(max_idx) = .true.
267 end if
268 end do
269 end subroutine sort_recents_by_time
270
271 !> Extract string value from JSON line
272 subroutine extract_json_string(line, value)
273 character(len=*), intent(in) :: line
274 character(len=*), intent(out) :: value
275 integer :: start_quote, end_quote, colon_pos
276
277 value = ""
278
279 colon_pos = index(line, ':')
280 if (colon_pos == 0) return
281
282 start_quote = index(line(colon_pos:), '"')
283 if (start_quote == 0) return
284 start_quote = start_quote + colon_pos
285
286 end_quote = index(line(start_quote+1:), '"')
287 if (end_quote == 0) return
288 end_quote = end_quote + start_quote
289
290 if (end_quote > start_quote) then
291 value = line(start_quote+1:end_quote-1)
292 end if
293 end subroutine extract_json_string
294
295 !> Extract integer value from JSON line
296 subroutine extract_json_int(line, value)
297 character(len=*), intent(in) :: line
298 integer, intent(out) :: value
299 character(len=32) :: num_str
300 integer :: colon_pos, comma_pos, ios
301
302 value = 0
303
304 colon_pos = index(line, ':')
305 if (colon_pos == 0) return
306
307 comma_pos = index(line(colon_pos:), ',')
308 if (comma_pos == 0) comma_pos = len_trim(line) - colon_pos + 1
309
310 num_str = adjustl(line(colon_pos+1:colon_pos+comma_pos-1))
311 read(num_str, *, iostat=ios) value
312 if (ios /= 0) value = 0
313 end subroutine extract_json_int
314
315 !> Get current timestamp in ISO 8601 format
316 subroutine get_current_timestamp(timestamp)
317 character(len=*), intent(out) :: timestamp
318 integer :: values(8)
319
320 call date_and_time(values=values)
321
322 write(timestamp, '(I4.4,A,I2.2,A,I2.2,A,I2.2,A,I2.2,A,I2.2,A)') &
323 values(1), '-', values(2), '-', values(3), 'T', &
324 values(5), ':', values(6), ':', values(7), 'Z'
325 end subroutine get_current_timestamp
326
327 !> Remove a recent workspace by index (Phase 7: handle deleted workspaces)
328 subroutine recents_remove(index, success)
329 integer, intent(in) :: index
330 logical, intent(out) :: success
331 type(recent_t), allocatable :: recents(:)
332 integer :: count, max_recents, i
333
334 success = .false.
335
336 ! Load existing recents
337 call recents_load(recents, count, max_recents, success)
338 if (.not. success) return
339
340 ! Check bounds
341 if (index < 1 .or. index > count) then
342 success = .false.
343 return
344 end if
345
346 ! Shift entries after the removed one
347 do i = index, count - 1
348 recents(i) = recents(i + 1)
349 end do
350 count = count - 1
351
352 ! Save updated list
353 call recents_save(recents, count, max_recents, success)
354 end subroutine recents_remove
355
356 end module recents_module
357