Repository navigation
Expand file tree
/
Copy pathapply-study-builder-hotfix.mjs
More file actions
executable file
·334 lines (301 loc) · 11.6 KB
/
Copy pathapply-study-builder-hotfix.mjs
File metadata and controls
executable file
·334 lines (301 loc) · 11.6 KB
1
2
3
4
5
6
7
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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
116
117
118
119
120
121
122
123
124
125
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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
#!/usr/bin/env node
import { access, readFile, rename, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
async function exists(path) {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
async function findRepositoryRoot() {
const candidates = [scriptDirectory, process.cwd(), resolve(scriptDirectory, "..")];
for (const candidate of candidates) {
if (await exists(join(candidate, "prototype", "package.json"))) return candidate;
}
throw new Error(
"저장소 루트를 찾지 못했습니다. 이 파일을 study-builder 저장소 루트에 두고 실행하세요.",
);
}
function replaceRegion(source, startMarker, endMarker, replacement, label) {
const start = source.indexOf(startMarker);
if (start < 0) throw new Error(`${label}: 시작 위치를 찾지 못했습니다.`);
const end = source.indexOf(endMarker, start + startMarker.length);
if (end < 0) throw new Error(`${label}: 끝 위치를 찾지 못했습니다.`);
return source.slice(0, start) + replacement + source.slice(end);
}
function replaceMethodRegion(source, startMarker, endMarker, transform, label) {
const start = source.indexOf(startMarker);
if (start < 0) throw new Error(`${label}: 메서드 시작 위치를 찾지 못했습니다.`);
const end = source.indexOf(endMarker, start + startMarker.length);
if (end < 0) throw new Error(`${label}: 다음 메서드 위치를 찾지 못했습니다.`);
const current = source.slice(start, end);
const next = transform(current);
return source.slice(0, start) + next + source.slice(end);
}
async function writeAtomic(path, content) {
const temporary = `${path}.study-builder-${process.pid}.tmp`;
await writeFile(temporary, content, "utf8");
await rename(temporary, path);
}
const normalizeSpringCourse = `function normalizeSpringCourse(data: AppData): AppData {
const storedById = new Map<string, StoredWikiDocument>(
data.wikiDocuments.map((document) => [document.id, document]),
);
const legacyById = new Map<string, StoredWikiDocument>(
LEGACY_SPRING_COURSE_WIKI_DOCUMENTS.map((document) => [document.id, document]),
);
const consumedIds = new Set<string>();
const wikiDocuments: StoredWikiDocument[] = [];
for (const canonical of SPRING_COURSE_WIKI_DOCUMENTS) {
const current = storedById.get(canonical.id);
const priorCopyId = \`legacy-copy-\${canonical.id}\`;
const priorCopy = storedById.get(priorCopyId);
const legacySeed = legacyById.get(canonical.id);
const currentContent =
current === undefined ? undefined : withoutPracticeNotes(current);
const currentMatchesSeed =
currentContent !== undefined &&
(sameDocument(currentContent, canonical) ||
(legacySeed !== undefined && sameDocument(currentContent, legacySeed)));
const selected =
priorCopy !== undefined && (current === undefined || currentMatchesSeed)
? {
...priorCopy,
id: canonical.id,
bookId: canonical.bookId,
title: priorCopy.title.replace(/ \\(이전 편집본\\)$/u, ""),
}
: current === undefined || currentMatchesSeed
? canonical
: current;
wikiDocuments.push(selected);
consumedIds.add(canonical.id);
consumedIds.add(priorCopyId);
}
for (const document of data.wikiDocuments) {
if (
consumedIds.has(document.id) ||
SPRING_COURSE_DOCUMENT_IDS.has(document.id)
) {
continue;
}
wikiDocuments.push(document);
}
const withSeededSpring = {
...data,
wikiDocuments,
};
const seed = createSeedData();
const economicsBook = seed.books.find((book) => book.id === "economics");
const economicsDocument = seed.wikiDocuments.find(
(document) => document.id === "economics-inflation",
);
if (economicsBook === undefined || economicsDocument === undefined) {
return withSeededSpring;
}
const hasEconomics = withSeededSpring.books.some((book) => book.id === "economics");
const hasEconomicsDocument = withSeededSpring.wikiDocuments.some(
(document) => document.id === "economics-inflation",
);
const withEconomics = hasEconomics
? {
...withSeededSpring,
books: withSeededSpring.books.map((book) =>
book.id === "economics"
? { ...economicsBook, progress: book.progress }
: book),
}
: {
...withSeededSpring,
books: [...withSeededSpring.books, economicsBook],
};
const withEconomicsDocument = hasEconomicsDocument
? withEconomics
: {
...withEconomics,
wikiDocuments: [...withEconomics.wikiDocuments, economicsDocument],
};
return JSON.stringify(withEconomicsDocument) === JSON.stringify(data)
? data
: withEconomicsDocument;
}
`;
const editableCourseTest = ` it("updates built-in course documents in place and preserves them after restart", async () => {
const { root, repository: subject } = await repository();
const course = await subject.getWikiDocument("spring-chapter-01");
const blocks = course.document.blocks.map((block, index) =>
index === 0
? { ...block, content: \`\${block.content}\\n직접 편집한 설명\` }
: block,
);
const updated = await subject.updateWikiDocument({
documentId: course.document.id,
expectedRevision: course.revision,
title: "Chapter 01. 직접 편집한 조회 API",
blocks,
});
expect(updated.document).toMatchObject({
id: course.document.id,
title: "Chapter 01. 직접 편집한 조회 API",
kind: "course",
readOnly: false,
});
await expect(subject.removeWikiDocument({
documentId: course.document.id,
expectedRevision: updated.revision,
})).rejects.toMatchObject({ code: "PERMISSION_DENIED" });
const restored = new AppRepository(
new JsonStore({ userDataRoot: root, clock }),
clock,
);
expect((await restored.getWikiDocument(course.document.id)).document)
.toMatchObject({
title: "Chapter 01. 직접 편집한 조회 API",
readOnly: false,
});
});
`;
function updateRepository(source) {
let next = source;
if (!next.includes("const storedById = new Map<string, StoredWikiDocument>")) {
next = replaceRegion(
next,
"function normalizeSpringCourse(data: AppData): AppData {",
"\nfunction externalDocument(",
normalizeSpringCourse,
"Spring 코스 정규화 로직",
);
}
next = next.replaceAll("readOnly: metadata !== undefined,", "readOnly: false,");
const editableFlags = next.match(/readOnly:\s*false,/gu)?.length ?? 0;
if (editableFlags < 2) {
throw new Error("Spring 위키의 편집 가능 상태를 적용하지 못했습니다.");
}
next = replaceMethodRegion(
next,
" listWikiDocuments(",
"\n getWikiDocument(",
(method) => {
if (method.includes('${metadata?.searchText ?? ""}\\n${title}')) return method;
const pattern = /searchText:\s*metadata\?\.searchText\s*\?\?\s*`\$\{title\}\\n\$\{document\.blocks[\s\S]*?\.join\("\\n"\)\}`\.toLowerCase\(\),/u;
if (!pattern.test(method)) {
throw new Error("편집된 Spring 문서를 검색에 포함할 위치를 찾지 못했습니다.");
}
return method.replace(
pattern,
`searchText: \`\${metadata?.searchText ?? ""}\\n\${title}\\n\${document.blocks
.map((block) => block.content)
.join("\\n")}\`.toLowerCase(),`,
);
},
"위키 문서 검색",
);
next = replaceMethodRegion(
next,
" updateWikiDocument(input: WikiWrite)",
"\n removeWikiDocument(",
(method) => {
const guard = /\n\s*if\s*\(SPRING_COURSE_DOCUMENT_IDS\.has\(input\.documentId\)\)\s*\{\s*throw new IpcContractError\("PERMISSION_DENIED"\);\s*\}\s*/u;
if (guard.test(method)) return method.replace(guard, "\n ");
if (method.includes('IpcContractError("PERMISSION_DENIED")')) {
throw new Error("Spring 문서 쓰기 권한 검사 위치가 예상과 다릅니다.");
}
return method;
},
"Spring 문서 직접 편집",
);
const storedStart = next.indexOf("function storedBlocks(");
const storedEnd = next.indexOf("\nexport class AppRepository", storedStart);
if (storedStart < 0 || storedEnd < 0) {
throw new Error("블록 저장 검증 함수를 찾지 못했습니다.");
}
let storedRegion = next.slice(storedStart, storedEnd);
if (!storedRegion.includes('block.type !== "image"')) {
const sourceCondition = 'block.type !== "source")';
if (!storedRegion.includes(sourceCondition)) {
throw new Error("이미지 블록 저장 조건을 추가할 위치를 찾지 못했습니다.");
}
storedRegion = storedRegion.replace(
sourceCondition,
'block.type !== "source" &&\n block.type !== "image")',
);
next = next.slice(0, storedStart) + storedRegion + next.slice(storedEnd);
}
return next;
}
function updateRepositoryTest(source) {
if (
source.includes(
'it("updates built-in course documents in place and preserves them after restart"',
)
) {
return source;
}
const startMarker =
' it("keeps built-in course documents read-only at the repository boundary"';
const start = source.indexOf(startMarker);
if (start < 0) {
console.warn(
"경고: 기존 read-only 단위 테스트를 찾지 못했습니다. 저장소 코드는 적용했지만 테스트 파일은 건드리지 않았습니다.",
);
return source;
}
const nextTest = source.indexOf("\n it(", start + startMarker.length);
const suiteEnd = source.lastIndexOf("\n});");
const end = nextTest >= 0 ? nextTest : suiteEnd;
if (end < 0) throw new Error("교체할 단위 테스트의 끝을 찾지 못했습니다.");
return source.slice(0, start) + editableCourseTest + source.slice(end);
}
async function main() {
const root = await findRepositoryRoot();
const repositoryPath = join(
root,
"prototype",
"electron",
"storage",
"app-repository.ts",
);
const testPath = join(
root,
"prototype",
"tests",
"unit",
"app-repository.test.ts",
);
const [repositorySource, testSource] = await Promise.all([
readFile(repositoryPath, "utf8"),
readFile(testPath, "utf8"),
]);
const nextRepository = updateRepository(repositorySource);
const nextTest = updateRepositoryTest(testSource);
await Promise.all([
nextRepository === repositorySource
? Promise.resolve()
: writeAtomic(repositoryPath, nextRepository),
nextTest === testSource ? Promise.resolve() : writeAtomic(testPath, nextTest),
]);
const expectedUiFiles = [
"prototype/src/wiki/CourseWorkspaceHeader.jsx",
"prototype/src/wiki/SyntaxHighlightedCode.jsx",
];
const missingUiFiles = [];
for (const relativePath of expectedUiFiles) {
if (!(await exists(join(root, relativePath)))) missingUiFiles.push(relativePath);
}
console.log("Spring Boot 위키 직접 편집 저장소 변경을 적용했습니다.");
console.log("기본 코스 문서는 수정할 수 있고, 삭제는 계속 차단됩니다.");
if (missingUiFiles.length > 0) {
console.warn("다음 UI 파일이 없습니다. 이 ZIP을 저장소 루트에 다시 덮어쓰세요:");
missingUiFiles.forEach((path) => console.warn(`- ${path}`));
}
console.log("검증 명령: cd prototype && npm run typecheck && npm test");
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});