@m16khb/llm-wiki 0.1.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.
@@ -0,0 +1,245 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "os"
10
+ "path/filepath"
11
+ "time"
12
+
13
+ _ "modernc.org/sqlite"
14
+ )
15
+
16
+ // Store persists daemon queue/audit state in SQLite WAL mode.
17
+ type Store struct {
18
+ Path string
19
+ db *sql.DB
20
+ now func() time.Time
21
+ }
22
+
23
+ // Job records one queued daemon operation.
24
+ type Job struct {
25
+ ID int64 `json:"id"`
26
+ Kind string `json:"kind"`
27
+ Status string `json:"status"`
28
+ PayloadJSON string `json:"payload_json"`
29
+ ResultJSON string `json:"result_json,omitempty"`
30
+ Error string `json:"error,omitempty"`
31
+ CreatedAt string `json:"created_at"`
32
+ UpdatedAt string `json:"updated_at"`
33
+ StartedAt string `json:"started_at,omitempty"`
34
+ FinishedAt string `json:"finished_at,omitempty"`
35
+ }
36
+
37
+ const (
38
+ StatusQueued = "queued"
39
+ StatusRunning = "running"
40
+ StatusCompleted = "completed"
41
+ StatusFailed = "failed"
42
+ )
43
+
44
+ // DefaultPath returns the default durable SQLite queue path.
45
+ func DefaultPath() string {
46
+ if v := os.Getenv("LLM_WIKI_DB"); v != "" {
47
+ return expandHome(v)
48
+ }
49
+ dir, err := os.UserConfigDir()
50
+ if err != nil || dir == "" {
51
+ if home, homeErr := os.UserHomeDir(); homeErr == nil && home != "" {
52
+ dir = filepath.Join(home, ".config")
53
+ } else {
54
+ dir = "."
55
+ }
56
+ }
57
+ return filepath.Join(dir, "llm-wiki", "llm-wiki.db")
58
+ }
59
+
60
+ // Open opens and migrates a SQLite-backed queue store.
61
+ func Open(path string) (*Store, error) {
62
+ if path == "" {
63
+ path = DefaultPath()
64
+ }
65
+ path = expandHome(path)
66
+ abs, err := filepath.Abs(path)
67
+ if err != nil {
68
+ return nil, err
69
+ }
70
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
71
+ return nil, err
72
+ }
73
+ db, err := sql.Open("sqlite", abs)
74
+ if err != nil {
75
+ return nil, err
76
+ }
77
+ db.SetMaxOpenConns(1)
78
+ s := &Store{Path: abs, db: db, now: time.Now}
79
+ if err := s.configure(context.Background()); err != nil {
80
+ db.Close()
81
+ return nil, err
82
+ }
83
+ if err := s.migrate(context.Background()); err != nil {
84
+ db.Close()
85
+ return nil, err
86
+ }
87
+ return s, nil
88
+ }
89
+
90
+ func (s *Store) Close() error {
91
+ if s == nil || s.db == nil {
92
+ return nil
93
+ }
94
+ return s.db.Close()
95
+ }
96
+
97
+ func (s *Store) configure(ctx context.Context) error {
98
+ pragmas := []string{
99
+ "PRAGMA journal_mode = WAL",
100
+ "PRAGMA synchronous = NORMAL",
101
+ "PRAGMA busy_timeout = 5000",
102
+ "PRAGMA foreign_keys = ON",
103
+ }
104
+ for _, stmt := range pragmas {
105
+ if _, err := s.db.ExecContext(ctx, stmt); err != nil {
106
+ return fmt.Errorf("configure sqlite %q: %w", stmt, err)
107
+ }
108
+ }
109
+ return nil
110
+ }
111
+
112
+ func (s *Store) migrate(ctx context.Context) error {
113
+ stmts := []string{
114
+ `CREATE TABLE IF NOT EXISTS jobs (
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ kind TEXT NOT NULL,
117
+ status TEXT NOT NULL,
118
+ payload_json TEXT NOT NULL,
119
+ result_json TEXT,
120
+ error TEXT,
121
+ created_at TEXT NOT NULL,
122
+ updated_at TEXT NOT NULL,
123
+ started_at TEXT,
124
+ finished_at TEXT
125
+ )`,
126
+ `CREATE INDEX IF NOT EXISTS idx_jobs_status_id ON jobs(status, id)`,
127
+ `CREATE INDEX IF NOT EXISTS idx_jobs_kind_id ON jobs(kind, id)`,
128
+ }
129
+ for _, stmt := range stmts {
130
+ if _, err := s.db.ExecContext(ctx, stmt); err != nil {
131
+ return fmt.Errorf("migrate sqlite: %w", err)
132
+ }
133
+ }
134
+ return nil
135
+ }
136
+
137
+ // Enqueue stores a job payload durably before the daemon worker executes it.
138
+ func (s *Store) Enqueue(ctx context.Context, kind string, payload any) (int64, error) {
139
+ if kind == "" {
140
+ return 0, errors.New("job kind is required")
141
+ }
142
+ data, err := json.Marshal(payload)
143
+ if err != nil {
144
+ return 0, fmt.Errorf("marshal job payload: %w", err)
145
+ }
146
+ now := s.timestamp()
147
+ res, err := s.db.ExecContext(ctx, `INSERT INTO jobs(kind, status, payload_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?)`, kind, StatusQueued, string(data), now, now)
148
+ if err != nil {
149
+ return 0, fmt.Errorf("enqueue job: %w", err)
150
+ }
151
+ id, err := res.LastInsertId()
152
+ if err != nil {
153
+ return 0, fmt.Errorf("enqueue job id: %w", err)
154
+ }
155
+ return id, nil
156
+ }
157
+
158
+ func (s *Store) MarkRunning(ctx context.Context, id int64) error {
159
+ now := s.timestamp()
160
+ return s.update(ctx, id, `UPDATE jobs SET status = ?, started_at = ?, updated_at = ? WHERE id = ?`, StatusRunning, now, now, id)
161
+ }
162
+
163
+ func (s *Store) MarkCompleted(ctx context.Context, id int64, result any) error {
164
+ data, err := json.Marshal(result)
165
+ if err != nil {
166
+ return fmt.Errorf("marshal job result: %w", err)
167
+ }
168
+ now := s.timestamp()
169
+ return s.update(ctx, id, `UPDATE jobs SET status = ?, result_json = ?, error = NULL, finished_at = ?, updated_at = ? WHERE id = ?`, StatusCompleted, string(data), now, now, id)
170
+ }
171
+
172
+ func (s *Store) MarkFailed(ctx context.Context, id int64, jobErr error) error {
173
+ msg := ""
174
+ if jobErr != nil {
175
+ msg = jobErr.Error()
176
+ }
177
+ now := s.timestamp()
178
+ return s.update(ctx, id, `UPDATE jobs SET status = ?, error = ?, finished_at = ?, updated_at = ? WHERE id = ?`, StatusFailed, msg, now, now, id)
179
+ }
180
+
181
+ func (s *Store) update(ctx context.Context, id int64, stmt string, args ...any) error {
182
+ res, err := s.db.ExecContext(ctx, stmt, args...)
183
+ if err != nil {
184
+ return fmt.Errorf("update job %d: %w", id, err)
185
+ }
186
+ n, err := res.RowsAffected()
187
+ if err != nil {
188
+ return fmt.Errorf("update job %d rows: %w", id, err)
189
+ }
190
+ if n == 0 {
191
+ return fmt.Errorf("job not found: %d", id)
192
+ }
193
+ return nil
194
+ }
195
+
196
+ func (s *Store) Job(ctx context.Context, id int64) (Job, error) {
197
+ row := s.db.QueryRowContext(ctx, `SELECT id, kind, status, payload_json, COALESCE(result_json, ''), COALESCE(error, ''), created_at, updated_at, COALESCE(started_at, ''), COALESCE(finished_at, '') FROM jobs WHERE id = ?`, id)
198
+ return scanJob(row)
199
+ }
200
+
201
+ func (s *Store) RecentJobs(ctx context.Context, limit int) ([]Job, error) {
202
+ if limit <= 0 || limit > 100 {
203
+ limit = 20
204
+ }
205
+ rows, err := s.db.QueryContext(ctx, `SELECT id, kind, status, payload_json, COALESCE(result_json, ''), COALESCE(error, ''), created_at, updated_at, COALESCE(started_at, ''), COALESCE(finished_at, '') FROM jobs ORDER BY id DESC LIMIT ?`, limit)
206
+ if err != nil {
207
+ return nil, err
208
+ }
209
+ defer rows.Close()
210
+ var jobs []Job
211
+ for rows.Next() {
212
+ job, err := scanJob(rows)
213
+ if err != nil {
214
+ return nil, err
215
+ }
216
+ jobs = append(jobs, job)
217
+ }
218
+ return jobs, rows.Err()
219
+ }
220
+
221
+ func scanJob(scanner interface{ Scan(dest ...any) error }) (Job, error) {
222
+ var j Job
223
+ if err := scanner.Scan(&j.ID, &j.Kind, &j.Status, &j.PayloadJSON, &j.ResultJSON, &j.Error, &j.CreatedAt, &j.UpdatedAt, &j.StartedAt, &j.FinishedAt); err != nil {
224
+ return Job{}, err
225
+ }
226
+ return j, nil
227
+ }
228
+
229
+ func (s *Store) timestamp() string {
230
+ return s.now().UTC().Format(time.RFC3339Nano)
231
+ }
232
+
233
+ func expandHome(path string) string {
234
+ if path == "~" {
235
+ if home, err := os.UserHomeDir(); err == nil {
236
+ return home
237
+ }
238
+ }
239
+ if len(path) >= 2 && path[:2] == "~/" {
240
+ if home, err := os.UserHomeDir(); err == nil {
241
+ return filepath.Join(home, path[2:])
242
+ }
243
+ }
244
+ return path
245
+ }
@@ -0,0 +1,48 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ func TestStorePersistsQueueJobs(t *testing.T) {
10
+ path := filepath.Join(t.TempDir(), "queue.db")
11
+ s, err := Open(path)
12
+ if err != nil {
13
+ t.Fatalf("Open() error = %v", err)
14
+ }
15
+ defer s.Close()
16
+
17
+ ctx := context.Background()
18
+ id, err := s.Enqueue(ctx, "wiki_capture", map[string]string{"title": "A"})
19
+ if err != nil {
20
+ t.Fatalf("Enqueue() error = %v", err)
21
+ }
22
+ if err := s.MarkRunning(ctx, id); err != nil {
23
+ t.Fatalf("MarkRunning() error = %v", err)
24
+ }
25
+ if err := s.MarkCompleted(ctx, id, map[string]string{"path": "20-wiki/concepts/a.md"}); err != nil {
26
+ t.Fatalf("MarkCompleted() error = %v", err)
27
+ }
28
+ job, err := s.Job(ctx, id)
29
+ if err != nil {
30
+ t.Fatalf("Job() error = %v", err)
31
+ }
32
+ if job.Status != StatusCompleted || job.ResultJSON == "" || job.StartedAt == "" || job.FinishedAt == "" {
33
+ t.Fatalf("unexpected job: %+v", job)
34
+ }
35
+
36
+ reopened, err := Open(path)
37
+ if err != nil {
38
+ t.Fatalf("reopen error = %v", err)
39
+ }
40
+ defer reopened.Close()
41
+ jobs, err := reopened.RecentJobs(ctx, 10)
42
+ if err != nil {
43
+ t.Fatalf("RecentJobs() error = %v", err)
44
+ }
45
+ if len(jobs) != 1 || jobs[0].ID != id || jobs[0].Status != StatusCompleted {
46
+ t.Fatalf("unexpected jobs after reopen: %+v", jobs)
47
+ }
48
+ }
@@ -0,0 +1,177 @@
1
+ package wiki
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "regexp"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ // CaptureInput describes an additive wiki write.
14
+ type CaptureInput struct {
15
+ Title string `json:"title" jsonschema:"human title for the page"`
16
+ Body string `json:"body" jsonschema:"markdown body without frontmatter"`
17
+ Type string `json:"type,omitempty" jsonschema:"concept, entity, summary, session, reference, or lint-report"`
18
+ Status string `json:"status,omitempty" jsonschema:"draft, active, or archived"`
19
+ Domain string `json:"domain,omitempty" jsonschema:"frontmatter domain value"`
20
+ Tags []string `json:"tags,omitempty" jsonschema:"frontmatter tags"`
21
+ Folder string `json:"folder,omitempty" jsonschema:"optional target folder under 20-wiki, 30-sessions, or 00-meta/reports"`
22
+ Slug string `json:"slug,omitempty" jsonschema:"optional kebab-case filename without .md"`
23
+ Overwrite bool `json:"overwrite,omitempty" jsonschema:"if true, replace an existing file in allowed folders"`
24
+ }
25
+
26
+ // CaptureResult describes a written page.
27
+ type CaptureResult struct {
28
+ Path string `json:"path"`
29
+ Slug string `json:"slug"`
30
+ Created bool `json:"created"`
31
+ Overwrote bool `json:"overwrote"`
32
+ }
33
+
34
+ func (v *Vault) Capture(in CaptureInput) (CaptureResult, error) {
35
+ in.Title = strings.TrimSpace(in.Title)
36
+ if in.Title == "" {
37
+ return CaptureResult{}, errors.New("title is required")
38
+ }
39
+ if strings.TrimSpace(in.Body) == "" {
40
+ return CaptureResult{}, errors.New("body is required")
41
+ }
42
+ if in.Type == "" {
43
+ in.Type = "concept"
44
+ }
45
+ if in.Status == "" {
46
+ in.Status = "draft"
47
+ }
48
+ if in.Domain == "" {
49
+ in.Domain = "dev-fundamentals"
50
+ }
51
+ slug := in.Slug
52
+ if slug == "" {
53
+ slug = Slugify(in.Title)
54
+ }
55
+ if slug == "" {
56
+ slug = "note-" + strings.ReplaceAll(v.nowTime().Format("20060102-150405"), ":", "-")
57
+ }
58
+ folder := v.chooseFolder(in)
59
+ if !isAllowedWriteFolder(folder) {
60
+ return CaptureResult{}, fmt.Errorf("folder is not allowed for writes: %s", folder)
61
+ }
62
+ rel := filepath.ToSlash(filepath.Join(folder, slug+".md"))
63
+ abs, err := v.SafeJoin(rel)
64
+ if err != nil {
65
+ return CaptureResult{}, err
66
+ }
67
+ if _, err := os.Stat(abs); err == nil && !in.Overwrite {
68
+ return CaptureResult{}, fmt.Errorf("page already exists: %s", rel)
69
+ }
70
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
71
+ return CaptureResult{}, err
72
+ }
73
+ now := v.nowDate()
74
+ content := renderCapture(now, slug, in)
75
+ _, statErr := os.Stat(abs)
76
+ if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
77
+ return CaptureResult{}, err
78
+ }
79
+ return CaptureResult{Path: rel, Slug: slug, Created: os.IsNotExist(statErr), Overwrote: statErr == nil}, nil
80
+ }
81
+
82
+ func (v *Vault) chooseFolder(in CaptureInput) string {
83
+ if strings.TrimSpace(in.Folder) != "" {
84
+ return strings.Trim(strings.TrimSpace(filepath.ToSlash(in.Folder)), "/")
85
+ }
86
+ year := v.nowDate()[:4]
87
+ switch in.Type {
88
+ case "session":
89
+ return "30-sessions/" + year
90
+ case "summary":
91
+ return "20-wiki/summaries/" + year
92
+ case "entity":
93
+ return "20-wiki/entities"
94
+ case "lint-report", "report":
95
+ return "00-meta/reports"
96
+ default:
97
+ return "20-wiki/concepts"
98
+ }
99
+ }
100
+
101
+ func isAllowedWriteFolder(folder string) bool {
102
+ folder = strings.Trim(strings.TrimSpace(filepath.ToSlash(folder)), "/")
103
+ allowedPrefixes := []string{
104
+ "20-wiki/concepts",
105
+ "20-wiki/entities",
106
+ "20-wiki/summaries",
107
+ "30-sessions",
108
+ "00-meta/reports",
109
+ }
110
+ for _, prefix := range allowedPrefixes {
111
+ if folder == prefix || strings.HasPrefix(folder, prefix+"/") {
112
+ return true
113
+ }
114
+ }
115
+ return false
116
+ }
117
+
118
+ func renderCapture(date, slug string, in CaptureInput) string {
119
+ var b strings.Builder
120
+ b.WriteString("---\n")
121
+ fmt.Fprintf(&b, "title: %s\n", yamlQuote(in.Title))
122
+ fmt.Fprintf(&b, "type: %s\n", in.Type)
123
+ fmt.Fprintf(&b, "status: %s\n", in.Status)
124
+ fmt.Fprintf(&b, "created: %s\n", date)
125
+ fmt.Fprintf(&b, "updated: %s\n", date)
126
+ fmt.Fprintf(&b, "tags: [%s]\n", joinYAMLList(in.Tags))
127
+ fmt.Fprintf(&b, "domain: %s\n", in.Domain)
128
+ b.WriteString("---\n\n")
129
+ if !strings.HasPrefix(strings.TrimSpace(in.Body), "#") {
130
+ fmt.Fprintf(&b, "# %s\n\n", in.Title)
131
+ }
132
+ b.WriteString(strings.TrimSpace(in.Body))
133
+ b.WriteString("\n\n## Changelog\n\n")
134
+ fmt.Fprintf(&b, "- %s: Created via llm-wiki MCP capture (`%s`).\n", date, slug)
135
+ return b.String()
136
+ }
137
+
138
+ func (v *Vault) nowTime() time.Time {
139
+ if v.Now != nil {
140
+ return v.Now()
141
+ }
142
+ return time.Now()
143
+ }
144
+
145
+ func (v *Vault) nowDate() string {
146
+ return v.nowTime().Format("2006-01-02")
147
+ }
148
+
149
+ var slugInvalid = regexp.MustCompile(`[^a-z0-9]+`)
150
+
151
+ func Slugify(s string) string {
152
+ s = strings.ToLower(strings.TrimSpace(s))
153
+ s = slugInvalid.ReplaceAllString(s, "-")
154
+ s = strings.Trim(s, "-")
155
+ return s
156
+ }
157
+
158
+ func yamlQuote(s string) string {
159
+ s = strings.ReplaceAll(s, "\\", "\\\\")
160
+ s = strings.ReplaceAll(s, "\"", "\\\"")
161
+ return "\"" + s + "\""
162
+ }
163
+
164
+ func joinYAMLList(values []string) string {
165
+ if len(values) == 0 {
166
+ return ""
167
+ }
168
+ parts := make([]string, 0, len(values))
169
+ for _, v := range values {
170
+ v = strings.TrimSpace(v)
171
+ if v == "" {
172
+ continue
173
+ }
174
+ parts = append(parts, v)
175
+ }
176
+ return strings.Join(parts, ", ")
177
+ }
@@ -0,0 +1,158 @@
1
+ package wiki
2
+
3
+ import (
4
+ "bufio"
5
+ "strings"
6
+ )
7
+
8
+ // Metadata is the subset of Obsidian/YAML frontmatter the adapter needs.
9
+ type Metadata struct {
10
+ Title string `json:"title,omitempty"`
11
+ Type string `json:"type,omitempty"`
12
+ Status string `json:"status,omitempty"`
13
+ Created string `json:"created,omitempty"`
14
+ Updated string `json:"updated,omitempty"`
15
+ Domain string `json:"domain,omitempty"`
16
+ Tags []string `json:"tags,omitempty"`
17
+ Sources []string `json:"sources,omitempty"`
18
+ Related []string `json:"related,omitempty"`
19
+ }
20
+
21
+ // ParseFrontmatter extracts a conservative subset of YAML frontmatter. It is
22
+ // intentionally dependency-free: the LLM Wiki schema uses simple scalar fields
23
+ // and string/list fields that this parser handles.
24
+ func ParseFrontmatter(content string) (Metadata, string, bool) {
25
+ var meta Metadata
26
+ if !strings.HasPrefix(content, "---\n") && content != "---" {
27
+ return meta, content, false
28
+ }
29
+ lines := strings.Split(content, "\n")
30
+ if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
31
+ return meta, content, false
32
+ }
33
+ end := -1
34
+ for i := 1; i < len(lines); i++ {
35
+ if strings.TrimSpace(lines[i]) == "---" {
36
+ end = i
37
+ break
38
+ }
39
+ }
40
+ if end < 0 {
41
+ return meta, content, false
42
+ }
43
+ body := strings.Join(lines[end+1:], "\n")
44
+ parseMetaBlock(strings.Join(lines[1:end], "\n"), &meta)
45
+ return meta, body, true
46
+ }
47
+
48
+ func parseMetaBlock(block string, meta *Metadata) {
49
+ s := bufio.NewScanner(strings.NewReader(block))
50
+ var currentList string
51
+ for s.Scan() {
52
+ line := strings.TrimRight(s.Text(), "\r")
53
+ trimmed := strings.TrimSpace(line)
54
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
55
+ continue
56
+ }
57
+ if strings.HasPrefix(trimmed, "- ") && currentList != "" {
58
+ appendList(meta, currentList, cleanScalar(strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))))
59
+ continue
60
+ }
61
+ currentList = ""
62
+ parts := strings.SplitN(trimmed, ":", 2)
63
+ if len(parts) != 2 {
64
+ continue
65
+ }
66
+ key := strings.TrimSpace(parts[0])
67
+ value := strings.TrimSpace(parts[1])
68
+ if value == "" {
69
+ currentList = key
70
+ continue
71
+ }
72
+ switch key {
73
+ case "title":
74
+ meta.Title = cleanScalar(value)
75
+ case "type":
76
+ meta.Type = cleanScalar(value)
77
+ case "status":
78
+ meta.Status = cleanScalar(value)
79
+ case "created":
80
+ meta.Created = cleanScalar(value)
81
+ case "updated":
82
+ meta.Updated = cleanScalar(value)
83
+ case "domain":
84
+ meta.Domain = cleanScalar(value)
85
+ case "tags":
86
+ meta.Tags = parseInlineList(value)
87
+ case "sources":
88
+ meta.Sources = parseInlineList(value)
89
+ case "related":
90
+ meta.Related = parseInlineList(value)
91
+ }
92
+ }
93
+ }
94
+
95
+ func appendList(meta *Metadata, key string, value string) {
96
+ switch key {
97
+ case "tags":
98
+ meta.Tags = append(meta.Tags, value)
99
+ case "sources":
100
+ meta.Sources = append(meta.Sources, value)
101
+ case "related":
102
+ meta.Related = append(meta.Related, value)
103
+ }
104
+ }
105
+
106
+ func parseInlineList(value string) []string {
107
+ value = strings.TrimSpace(value)
108
+ if value == "" || value == "[]" {
109
+ return nil
110
+ }
111
+ if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
112
+ value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "["), "]"))
113
+ if value == "" {
114
+ return nil
115
+ }
116
+ parts := splitCSV(value)
117
+ out := make([]string, 0, len(parts))
118
+ for _, p := range parts {
119
+ if cleaned := cleanScalar(p); cleaned != "" {
120
+ out = append(out, cleaned)
121
+ }
122
+ }
123
+ return out
124
+ }
125
+ return []string{cleanScalar(value)}
126
+ }
127
+
128
+ func splitCSV(s string) []string {
129
+ var parts []string
130
+ var b strings.Builder
131
+ quote := rune(0)
132
+ for _, r := range s {
133
+ switch {
134
+ case quote != 0:
135
+ if r == quote {
136
+ quote = 0
137
+ }
138
+ b.WriteRune(r)
139
+ case r == '\'' || r == '"':
140
+ quote = r
141
+ b.WriteRune(r)
142
+ case r == ',':
143
+ parts = append(parts, b.String())
144
+ b.Reset()
145
+ default:
146
+ b.WriteRune(r)
147
+ }
148
+ }
149
+ parts = append(parts, b.String())
150
+ return parts
151
+ }
152
+
153
+ func cleanScalar(value string) string {
154
+ value = strings.TrimSpace(value)
155
+ value = strings.Trim(value, "\"")
156
+ value = strings.Trim(value, "'")
157
+ return value
158
+ }