tina4ruby 3.11.35 → 3.12.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.
- checksums.yaml +4 -4
- data/lib/tina4/auth.rb +5 -5
- data/lib/tina4/background.rb +81 -0
- data/lib/tina4/constants.rb +40 -0
- data/lib/tina4/container.rb +1 -1
- data/lib/tina4/database.rb +37 -10
- data/lib/tina4/dev_admin.rb +464 -2
- data/lib/tina4/docs.rb +636 -0
- data/lib/tina4/drivers/postgres_driver.rb +38 -4
- data/lib/tina4/env.rb +74 -3
- data/lib/tina4/field_types.rb +1 -1
- data/lib/tina4/frond.rb +62 -0
- data/lib/tina4/mcp.rb +191 -1
- data/lib/tina4/messenger.rb +13 -14
- data/lib/tina4/orm.rb +85 -12
- data/lib/tina4/plan.rb +471 -0
- data/lib/tina4/project_index.rb +366 -0
- data/lib/tina4/public/js/frond.js +600 -0
- data/lib/tina4/public/js/frond.min.js +1 -1
- data/lib/tina4/public/js/tina4-dev-admin.js +1086 -238
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1142 -209
- data/lib/tina4/rack_app.rb +98 -16
- data/lib/tina4/response.rb +3 -0
- data/lib/tina4/session.rb +1 -1
- data/lib/tina4/session_handlers/database_handler.rb +1 -1
- data/lib/tina4/shutdown.rb +10 -0
- data/lib/tina4/swagger.rb +3 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +3 -0
- data/lib/tina4.rb +15 -1
- metadata +6 -1
data/lib/tina4/plan.rb
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4::Plan — project plan storage + manipulation, ported from
|
|
4
|
+
# tina4_python/dev_admin/plan.py. Plan files are canonical markdown
|
|
5
|
+
# under plan/ at the project root; exactly one plan is "current"
|
|
6
|
+
# (filename stored in plan/.current).
|
|
7
|
+
#
|
|
8
|
+
# File format is byte-for-byte identical to the Python reference —
|
|
9
|
+
# title (# Title), optional "Goal: ...", "## Steps" with "- [ ]" /
|
|
10
|
+
# "- [x]" checkboxes, and optional "## Notes" trailer.
|
|
11
|
+
|
|
12
|
+
require "json"
|
|
13
|
+
require "fileutils"
|
|
14
|
+
require "net/http"
|
|
15
|
+
require "uri"
|
|
16
|
+
|
|
17
|
+
module Tina4
|
|
18
|
+
module Plan
|
|
19
|
+
PLAN_DIR = "plan"
|
|
20
|
+
CURRENT_FILE = ".current"
|
|
21
|
+
ARCHIVE_SUBDIR = "done"
|
|
22
|
+
|
|
23
|
+
STEP_RE = /\A\s*[-*]\s*\[(?<box>[ xX])\]\s*(?<text>.+?)\s*\z/.freeze
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# ── Paths ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def project_root
|
|
29
|
+
File.expand_path(Dir.pwd)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def plan_dir
|
|
33
|
+
p = File.join(project_root, PLAN_DIR)
|
|
34
|
+
FileUtils.mkdir_p(p)
|
|
35
|
+
p
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def current_pointer
|
|
39
|
+
File.join(plan_dir, CURRENT_FILE)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def archive_dir
|
|
43
|
+
p = File.join(plan_dir, ARCHIVE_SUBDIR)
|
|
44
|
+
FileUtils.mkdir_p(p)
|
|
45
|
+
p
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def slugify(title)
|
|
49
|
+
slug = title.to_s.strip.downcase.gsub(/[^a-z0-9_\-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
50
|
+
slug = slug[0, 80]
|
|
51
|
+
slug.empty? ? "plan-#{Time.now.to_i}" : slug
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# ── Parse / render ─────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def parse(text)
|
|
57
|
+
lines = text.to_s.split("\n", -1)
|
|
58
|
+
title = ""
|
|
59
|
+
goal = ""
|
|
60
|
+
steps = []
|
|
61
|
+
notes_lines = []
|
|
62
|
+
section = nil # :steps | :notes | :other | nil
|
|
63
|
+
|
|
64
|
+
lines.each do |raw|
|
|
65
|
+
line = raw.sub(/[[:space:]]+\z/, "")
|
|
66
|
+
if title.empty? && line.start_with?("# ")
|
|
67
|
+
title = line[2..].to_s.strip
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
low = line.strip.downcase
|
|
71
|
+
if low.start_with?("goal:") && goal.empty?
|
|
72
|
+
goal = line.split(":", 2)[1].to_s.strip
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
if low == "## steps"
|
|
76
|
+
section = :steps
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
if low == "## notes"
|
|
80
|
+
section = :notes
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
if line.start_with?("## ")
|
|
84
|
+
section = :other
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
if section == :steps
|
|
88
|
+
m = STEP_RE.match(line)
|
|
89
|
+
steps << { "text" => m[:text].strip, "done" => m[:box].downcase == "x" } if m
|
|
90
|
+
elsif section == :notes && !line.strip.empty?
|
|
91
|
+
notes_lines << line
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
"title" => title,
|
|
97
|
+
"goal" => goal,
|
|
98
|
+
"steps" => steps,
|
|
99
|
+
"notes" => notes_lines.join("\n").strip
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render(plan)
|
|
104
|
+
parts = ["# #{plan["title"] || "Untitled plan"}", ""]
|
|
105
|
+
goal = plan["goal"]
|
|
106
|
+
if goal && !goal.to_s.empty?
|
|
107
|
+
parts << "Goal: #{goal}"
|
|
108
|
+
parts << ""
|
|
109
|
+
end
|
|
110
|
+
parts << "## Steps"
|
|
111
|
+
parts << ""
|
|
112
|
+
(plan["steps"] || []).each do |s|
|
|
113
|
+
box = s["done"] ? "x" : " "
|
|
114
|
+
parts << "- [#{box}] #{(s["text"] || "").to_s.strip}"
|
|
115
|
+
end
|
|
116
|
+
notes = (plan["notes"] || "").strip
|
|
117
|
+
if !notes.empty?
|
|
118
|
+
parts << ""
|
|
119
|
+
parts << "## Notes"
|
|
120
|
+
parts << ""
|
|
121
|
+
parts << notes
|
|
122
|
+
end
|
|
123
|
+
parts.join("\n") + "\n"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ── Public API ─────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def list_plans
|
|
129
|
+
d = plan_dir
|
|
130
|
+
cur = current_name || ""
|
|
131
|
+
out = []
|
|
132
|
+
Dir.glob(File.join(d, "*.md")).sort.each do |path|
|
|
133
|
+
name = File.basename(path)
|
|
134
|
+
parsed = parse(File.read(path, encoding: "utf-8"))
|
|
135
|
+
total = parsed["steps"].size
|
|
136
|
+
done = parsed["steps"].count { |s| s["done"] }
|
|
137
|
+
out << {
|
|
138
|
+
"name" => name,
|
|
139
|
+
"title" => parsed["title"].to_s.empty? ? File.basename(name, ".md") : parsed["title"],
|
|
140
|
+
"steps_total" => total,
|
|
141
|
+
"steps_done" => done,
|
|
142
|
+
"is_current" => name == cur
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
out
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def current_name
|
|
149
|
+
ptr = current_pointer
|
|
150
|
+
return "" unless File.exist?(ptr)
|
|
151
|
+
File.read(ptr, encoding: "utf-8").strip
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def set_current(name)
|
|
155
|
+
name = name.to_s.strip
|
|
156
|
+
name += ".md" unless name.end_with?(".md")
|
|
157
|
+
path = File.join(plan_dir, name)
|
|
158
|
+
return { "ok" => false, "error" => "No such plan: #{name}" } unless File.exist?(path)
|
|
159
|
+
File.write(current_pointer, name, encoding: "utf-8")
|
|
160
|
+
{ "ok" => true, "current" => name }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def clear_current
|
|
164
|
+
File.delete(current_pointer) if File.exist?(current_pointer)
|
|
165
|
+
{ "ok" => true }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def current
|
|
169
|
+
name = current_name
|
|
170
|
+
return { "current" => nil } if name.empty?
|
|
171
|
+
path = File.join(plan_dir, name)
|
|
172
|
+
unless File.exist?(path)
|
|
173
|
+
clear_current
|
|
174
|
+
return { "current" => nil, "warning" => "Current pointer referenced missing file: #{name}" }
|
|
175
|
+
end
|
|
176
|
+
parsed = parse(File.read(path, encoding: "utf-8"))
|
|
177
|
+
indexed = parsed["steps"].each_with_index.map do |s, i|
|
|
178
|
+
{ "index" => i, "text" => s["text"], "done" => s["done"] }
|
|
179
|
+
end
|
|
180
|
+
next_step = indexed.find { |s| !s["done"] }
|
|
181
|
+
{
|
|
182
|
+
"current" => name,
|
|
183
|
+
"title" => parsed["title"],
|
|
184
|
+
"goal" => parsed["goal"],
|
|
185
|
+
"steps" => indexed,
|
|
186
|
+
"next_step" => next_step,
|
|
187
|
+
"notes" => parsed["notes"],
|
|
188
|
+
"progress" => {
|
|
189
|
+
"done" => indexed.count { |s| s["done"] },
|
|
190
|
+
"total" => indexed.size
|
|
191
|
+
},
|
|
192
|
+
"execution" => summarise_execution(name)
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def read(name)
|
|
197
|
+
name = name.to_s
|
|
198
|
+
name += ".md" unless name.end_with?(".md")
|
|
199
|
+
path = File.join(plan_dir, name)
|
|
200
|
+
return { "error" => "No such plan: #{name}" } unless File.exist?(path)
|
|
201
|
+
parse(File.read(path, encoding: "utf-8")).merge("name" => name)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def create(title, goal: "", steps: nil, make_current: true)
|
|
205
|
+
title = title.to_s.strip
|
|
206
|
+
return { "ok" => false, "error" => "title is required" } if title.empty?
|
|
207
|
+
name = "#{slugify(title)}.md"
|
|
208
|
+
path = File.join(plan_dir, name)
|
|
209
|
+
if File.exist?(path)
|
|
210
|
+
return {
|
|
211
|
+
"ok" => false,
|
|
212
|
+
"error" => "Plan already exists: #{name}. Pick a different title or edit the existing one."
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
plan = {
|
|
216
|
+
"title" => title,
|
|
217
|
+
"goal" => goal.to_s.strip,
|
|
218
|
+
"steps" => (steps || []).map { |s| s.to_s.strip }.reject(&:empty?).map { |s| { "text" => s, "done" => false } },
|
|
219
|
+
"notes" => ""
|
|
220
|
+
}
|
|
221
|
+
File.write(path, render(plan), encoding: "utf-8")
|
|
222
|
+
File.write(current_pointer, name, encoding: "utf-8") if make_current
|
|
223
|
+
{ "ok" => true, "name" => name, "title" => title, "is_current" => make_current }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def complete_step(index, name = "")
|
|
227
|
+
target = load_for_mutation(name)
|
|
228
|
+
return target if target.is_a?(Hash) && target["ok"] == false
|
|
229
|
+
path, plan = target
|
|
230
|
+
steps = plan["steps"]
|
|
231
|
+
if index.negative? || index >= steps.size
|
|
232
|
+
return { "ok" => false, "error" => "Step index #{index} out of range (0..#{steps.size - 1})" }
|
|
233
|
+
end
|
|
234
|
+
steps[index]["done"] = true
|
|
235
|
+
File.write(path, render(plan), encoding: "utf-8")
|
|
236
|
+
remaining = steps.each_with_index.reject { |s, _| s["done"] }.map { |_, i| i }
|
|
237
|
+
{
|
|
238
|
+
"ok" => true,
|
|
239
|
+
"completed" => steps[index]["text"],
|
|
240
|
+
"remaining" => remaining.size,
|
|
241
|
+
"next_step" => remaining.empty? ? nil : steps[remaining.first]["text"]
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def uncomplete_step(index, name = "")
|
|
246
|
+
target = load_for_mutation(name)
|
|
247
|
+
return target if target.is_a?(Hash) && target["ok"] == false
|
|
248
|
+
path, plan = target
|
|
249
|
+
steps = plan["steps"]
|
|
250
|
+
if index.negative? || index >= steps.size
|
|
251
|
+
return { "ok" => false, "error" => "Step index #{index} out of range" }
|
|
252
|
+
end
|
|
253
|
+
steps[index]["done"] = false
|
|
254
|
+
File.write(path, render(plan), encoding: "utf-8")
|
|
255
|
+
{ "ok" => true, "step" => steps[index]["text"] }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def add_step(text, name = "")
|
|
259
|
+
text = text.to_s.strip
|
|
260
|
+
return { "ok" => false, "error" => "text is required" } if text.empty?
|
|
261
|
+
target = load_for_mutation(name)
|
|
262
|
+
return target if target.is_a?(Hash) && target["ok"] == false
|
|
263
|
+
path, plan = target
|
|
264
|
+
plan["steps"] << { "text" => text, "done" => false }
|
|
265
|
+
File.write(path, render(plan), encoding: "utf-8")
|
|
266
|
+
{ "ok" => true, "step" => text, "index" => plan["steps"].size - 1 }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def append_note(text, name = "")
|
|
270
|
+
text = text.to_s.strip
|
|
271
|
+
return { "ok" => false, "error" => "text is required" } if text.empty?
|
|
272
|
+
target = load_for_mutation(name)
|
|
273
|
+
return target if target.is_a?(Hash) && target["ok"] == false
|
|
274
|
+
path, plan = target
|
|
275
|
+
existing = (plan["notes"] || "").strip
|
|
276
|
+
stamp = Time.now.strftime("%Y-%m-%d %H:%M")
|
|
277
|
+
plan["notes"] = (existing + "\n- [#{stamp}] #{text}").strip
|
|
278
|
+
File.write(path, render(plan), encoding: "utf-8")
|
|
279
|
+
{ "ok" => true, "appended" => text }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# ── Execution ledger ───────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
def ledger_path(name = "")
|
|
285
|
+
name = name.to_s
|
|
286
|
+
name = current_name if name.empty?
|
|
287
|
+
return nil if name.empty?
|
|
288
|
+
name += ".md" unless name.end_with?(".md")
|
|
289
|
+
File.join(plan_dir, "#{name[0..-4]}.log.json")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def record_action(action, path, note: "")
|
|
293
|
+
lp = ledger_path
|
|
294
|
+
return nil if lp.nil?
|
|
295
|
+
entries = []
|
|
296
|
+
if File.exist?(lp)
|
|
297
|
+
begin
|
|
298
|
+
entries = JSON.parse(File.read(lp, encoding: "utf-8"))
|
|
299
|
+
rescue StandardError
|
|
300
|
+
entries = []
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
entries << {
|
|
304
|
+
"t" => Time.now.to_i,
|
|
305
|
+
"action" => action,
|
|
306
|
+
"path" => path,
|
|
307
|
+
"note" => note
|
|
308
|
+
}
|
|
309
|
+
entries = entries.last(500) if entries.size > 500
|
|
310
|
+
begin
|
|
311
|
+
File.write(lp, JSON.pretty_generate(entries), encoding: "utf-8")
|
|
312
|
+
rescue StandardError
|
|
313
|
+
# best-effort
|
|
314
|
+
end
|
|
315
|
+
nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def summarise_execution(name = "")
|
|
319
|
+
lp = ledger_path(name)
|
|
320
|
+
empty = { "created" => [], "patched" => [], "migrations" => [], "total" => 0 }
|
|
321
|
+
return empty if lp.nil? || !File.exist?(lp)
|
|
322
|
+
begin
|
|
323
|
+
entries = JSON.parse(File.read(lp, encoding: "utf-8"))
|
|
324
|
+
rescue StandardError
|
|
325
|
+
return empty
|
|
326
|
+
end
|
|
327
|
+
created = []
|
|
328
|
+
patched = []
|
|
329
|
+
migrations = []
|
|
330
|
+
entries.each do |e|
|
|
331
|
+
p = e["path"]
|
|
332
|
+
next if p.nil?
|
|
333
|
+
bucket = case e["action"]
|
|
334
|
+
when "migration" then migrations
|
|
335
|
+
when "created" then created
|
|
336
|
+
when "patched" then patched
|
|
337
|
+
end
|
|
338
|
+
bucket << p if bucket && !bucket.include?(p)
|
|
339
|
+
end
|
|
340
|
+
{
|
|
341
|
+
"created" => created.last(20),
|
|
342
|
+
"patched" => patched.last(20),
|
|
343
|
+
"migrations" => migrations.last(20),
|
|
344
|
+
"total" => entries.size
|
|
345
|
+
}
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# ── AI flesh-out ──────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
def flesh(name = "", prompt = "")
|
|
351
|
+
target = (name.to_s.strip.empty? ? current_name : name.to_s.strip)
|
|
352
|
+
return { "ok" => false, "error" => "No current plan and no name given" } if target.empty?
|
|
353
|
+
current_plan = read(target)
|
|
354
|
+
return { "ok" => false, "error" => current_plan["error"] } if current_plan["error"]
|
|
355
|
+
|
|
356
|
+
existing = (current_plan["steps"] || []).map { |s| s["text"].to_s }
|
|
357
|
+
title = current_plan["title"].to_s.empty? ? target : current_plan["title"]
|
|
358
|
+
goal = current_plan["goal"].to_s
|
|
359
|
+
|
|
360
|
+
system_prompt = (
|
|
361
|
+
"You are Tina4, a coding planner embedded in the Tina4 dev " \
|
|
362
|
+
"admin. Return ONLY a JSON array of short imperative step " \
|
|
363
|
+
"strings (no prose, no code-fences, no numbering). 3-8 steps, " \
|
|
364
|
+
"each referencing concrete files/routes/migrations. Example: " \
|
|
365
|
+
'["Create src/orm/Duck.rb with id/name/sighted_at", ' \
|
|
366
|
+
'"Add migration 001_create_ducks.sql", ' \
|
|
367
|
+
'"Add GET/POST/PUT/DELETE /api/ducks routes in src/routes/ducks.rb"]'
|
|
368
|
+
)
|
|
369
|
+
user_parts = ["Plan title: #{title}"]
|
|
370
|
+
user_parts << "Goal: #{goal}" unless goal.empty?
|
|
371
|
+
user_parts << "Existing steps (don't repeat):\n- " + existing.join("\n- ") unless existing.empty?
|
|
372
|
+
user_parts << "Extra context from caller: #{prompt}" unless prompt.to_s.empty?
|
|
373
|
+
user_parts << "Reply with ONLY the JSON array — no explanation, no markdown fences."
|
|
374
|
+
|
|
375
|
+
ai_url = ENV.fetch("TINA4_AI_URL", "http://localhost:11437/api/chat")
|
|
376
|
+
ai_model = ENV.fetch("TINA4_AI_MODEL", "qwen2.5-coder:14b")
|
|
377
|
+
|
|
378
|
+
reply = begin
|
|
379
|
+
uri = URI.parse(ai_url)
|
|
380
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
381
|
+
http.use_ssl = (uri.scheme == "https")
|
|
382
|
+
http.open_timeout = 10
|
|
383
|
+
http.read_timeout = 120
|
|
384
|
+
req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
|
|
385
|
+
req.body = JSON.generate({
|
|
386
|
+
"model" => ai_model,
|
|
387
|
+
"stream" => false,
|
|
388
|
+
"messages" => [
|
|
389
|
+
{ "role" => "system", "content" => system_prompt },
|
|
390
|
+
{ "role" => "user", "content" => user_parts.join("\n\n") }
|
|
391
|
+
]
|
|
392
|
+
})
|
|
393
|
+
resp = http.request(req)
|
|
394
|
+
body = JSON.parse(resp.body)
|
|
395
|
+
(body["message"].is_a?(Hash) ? body["message"]["content"] : nil) || body["response"] || ""
|
|
396
|
+
rescue StandardError => e
|
|
397
|
+
return { "ok" => false, "error" => "AI backend unreachable: #{e.message}" }
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
body = reply.to_s.strip
|
|
401
|
+
if body.start_with?("```")
|
|
402
|
+
body = body.gsub(/\A`+|`+\z/, "")
|
|
403
|
+
body = body[4..].to_s.strip if body.downcase.start_with?("json")
|
|
404
|
+
body = body.strip
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
proposed = []
|
|
408
|
+
begin
|
|
409
|
+
parsed = JSON.parse(body)
|
|
410
|
+
proposed = parsed.map { |x| x.to_s.strip }.reject(&:empty?) if parsed.is_a?(Array)
|
|
411
|
+
rescue StandardError
|
|
412
|
+
reply.split("\n").each do |line|
|
|
413
|
+
m = line.match(/\A\s*(?:[-*]|\d+[.)])\s+(.+?)\s*\z/)
|
|
414
|
+
proposed << m[1].strip if m
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
if proposed.empty?
|
|
419
|
+
return { "ok" => false, "error" => "AI returned no usable steps", "raw_reply" => reply.to_s[0, 400] }
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
existing_lc = existing.map(&:downcase).to_set rescue existing.map(&:downcase)
|
|
423
|
+
existing_lc = Set.new(existing_lc) if existing_lc.is_a?(Array)
|
|
424
|
+
added = []
|
|
425
|
+
proposed.each do |step|
|
|
426
|
+
next if existing_lc.include?(step.downcase)
|
|
427
|
+
res = add_step(step, target)
|
|
428
|
+
if res["ok"]
|
|
429
|
+
added << step
|
|
430
|
+
existing_lc << step.downcase
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
{
|
|
435
|
+
"ok" => true,
|
|
436
|
+
"plan" => target,
|
|
437
|
+
"added" => added,
|
|
438
|
+
"added_count" => added.size,
|
|
439
|
+
"proposed_count" => proposed.size,
|
|
440
|
+
"plan_after" => read(target)
|
|
441
|
+
}
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def archive(name = "")
|
|
445
|
+
target = name.to_s.strip.empty? ? current_name : name.to_s.strip
|
|
446
|
+
return { "ok" => false, "error" => "No current plan and no name given" } if target.empty?
|
|
447
|
+
target += ".md" unless target.end_with?(".md")
|
|
448
|
+
src = File.join(plan_dir, target)
|
|
449
|
+
return { "ok" => false, "error" => "No such plan: #{target}" } unless File.exist?(src)
|
|
450
|
+
dest = File.join(archive_dir, target)
|
|
451
|
+
dest = File.join(archive_dir, "#{Time.now.to_i}-#{target}") if File.exist?(dest)
|
|
452
|
+
File.rename(src, dest)
|
|
453
|
+
clear_current if current_name == target
|
|
454
|
+
{ "ok" => true, "archived_to" => dest.sub("#{project_root}/", "") }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
private
|
|
458
|
+
|
|
459
|
+
def load_for_mutation(name)
|
|
460
|
+
name = current_name if name.to_s.empty?
|
|
461
|
+
return { "ok" => false, "error" => "No current plan and no name given" } if name.empty?
|
|
462
|
+
name += ".md" unless name.end_with?(".md")
|
|
463
|
+
path = File.join(plan_dir, name)
|
|
464
|
+
return { "ok" => false, "error" => "No such plan: #{name}" } unless File.exist?(path)
|
|
465
|
+
[path, parse(File.read(path, encoding: "utf-8"))]
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
require "set"
|