texlite 0.7.0 → 0.7.3
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/dist/client/assets/{BinaryModule-Aj1vLnwf-wCY0vy1F.js → BinaryModule-Aj1vLnwf-_qIOaefD.js} +1 -1
- package/dist/client/assets/GitDialog-BmRzMv-C.js +3 -0
- package/dist/client/assets/{HistoryDialog-qZKt59En.js → HistoryDialog-V0IkSDtJ.js} +1 -1
- package/dist/client/assets/LatexEditor-CokkLLxi.js +59 -0
- package/dist/client/assets/PdfPreview-Cs1WYPsC.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-CcHy9xt5.js → ProjectNavigationDialogs-BVQb4qdD.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-BSiZRWs4.js → SystemMetricsDialog-DBrendnp.js} +1 -1
- package/dist/client/assets/binary-BQKEW09b.js +1 -0
- package/dist/client/assets/index-BS1IMOaB.js +62 -0
- package/dist/client/assets/index-Lw53KTaT.css +1 -0
- package/dist/client/assets/{index-BM28jYXo.js → index-QyVe7I1s.js} +1 -1
- package/dist/client/assets/minus-yEV4SLvG.js +1 -0
- package/dist/client/assets/{spellCheck-Cs5UpEXV.js → spellCheck-OZGa6ljx.js} +3 -3
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +315 -2
- package/dist/server/citationLibrary.js +174 -0
- package/dist/server/db.js +37 -0
- package/package.json +2 -1
- package/dist/client/assets/GitDialog-BNzmjKSK.js +0 -3
- package/dist/client/assets/LatexEditor-CpK2QB28.js +0 -59
- package/dist/client/assets/PdfPreview-Deo1bzQ7.js +0 -1
- package/dist/client/assets/binary-DEDGh9yV.js +0 -1
- package/dist/client/assets/index-DJzyhcDQ.js +0 -17
- package/dist/client/assets/index-DdrajKQB.css +0 -1
- package/dist/client/assets/plus-BmrCa7ds.js +0 -1
package/dist/server/app.js
CHANGED
|
@@ -31,6 +31,7 @@ import { compileMainFile } from "./compileArtifacts.js";
|
|
|
31
31
|
import { apiError, contentDisposition, ValidationError } from "./http.js";
|
|
32
32
|
import { registerCompileRoutes } from "./routes/compile.js";
|
|
33
33
|
const now = () => new Date().toISOString();
|
|
34
|
+
const MAX_CITATION_BIBTEX_BYTES = 512 * 1024;
|
|
34
35
|
const SESSION_CLEANUP_INTERVAL_MS = 15 * 60_000;
|
|
35
36
|
function escapeLikePattern(value) {
|
|
36
37
|
return value.replace(/[\\%_]/g, "\\$&");
|
|
@@ -52,6 +53,97 @@ function dictionaryWord(value) {
|
|
|
52
53
|
throw new ValidationError("自定义词格式不正确");
|
|
53
54
|
return word;
|
|
54
55
|
}
|
|
56
|
+
function citationJson(row, tags = []) {
|
|
57
|
+
return {
|
|
58
|
+
id: row.id,
|
|
59
|
+
citationKey: row.citation_key,
|
|
60
|
+
entryType: row.entry_type,
|
|
61
|
+
bibtex: row.bibtex,
|
|
62
|
+
title: row.title,
|
|
63
|
+
authors: row.authors,
|
|
64
|
+
year: row.year,
|
|
65
|
+
revision: row.revision,
|
|
66
|
+
tags: tags.map((tag) => ({ id: tag.id, name: tag.name, color: tag.color, ownerId: tag.user_id })),
|
|
67
|
+
ownerId: row.user_id,
|
|
68
|
+
ownerUsername: row.owner_username ?? null,
|
|
69
|
+
ownerDisplayName: row.owner_display_name ?? null,
|
|
70
|
+
createdAt: row.created_at,
|
|
71
|
+
updatedAt: row.updated_at
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function citationTagsForEntries(db, entryIds) {
|
|
75
|
+
const result = new Map(entryIds.map((entryId) => [entryId, []]));
|
|
76
|
+
if (!entryIds.length)
|
|
77
|
+
return result;
|
|
78
|
+
const placeholders = entryIds.map(() => "?").join(", ");
|
|
79
|
+
const rows = db.prepare(`SELECT link.entry_id, tag.id, tag.name, tag.color, tag.user_id
|
|
80
|
+
FROM citation_library_entry_tags link JOIN citation_library_tags tag ON tag.id = link.tag_id
|
|
81
|
+
JOIN citation_library_entries entry ON entry.id = link.entry_id AND entry.user_id = tag.user_id
|
|
82
|
+
WHERE link.entry_id IN (${placeholders})
|
|
83
|
+
ORDER BY tag.name COLLATE NOCASE`).all(...entryIds);
|
|
84
|
+
for (const row of rows)
|
|
85
|
+
result.get(row.entry_id)?.push({ id: row.id, name: row.name, color: row.color, user_id: row.user_id });
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
function citationTagIds(db, userId, value) {
|
|
89
|
+
if (value === undefined)
|
|
90
|
+
return null;
|
|
91
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
|
|
92
|
+
throw new ValidationError("引用标签格式不正确");
|
|
93
|
+
const ids = [...new Set(value.map((item) => item.trim()).filter(Boolean))];
|
|
94
|
+
if (ids.length > 100)
|
|
95
|
+
throw new ValidationError("单个引用最多只能设置 100 个标签");
|
|
96
|
+
if (!ids.length)
|
|
97
|
+
return [];
|
|
98
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
99
|
+
const rows = db.prepare(`SELECT id FROM citation_library_tags WHERE user_id = ? AND id IN (${placeholders})`)
|
|
100
|
+
.all(userId, ...ids);
|
|
101
|
+
if (rows.length !== ids.length)
|
|
102
|
+
throw Object.assign(new Error("引用标签不存在"), { statusCode: 404, code: "CITATION_TAG_NOT_FOUND" });
|
|
103
|
+
return ids;
|
|
104
|
+
}
|
|
105
|
+
function citationTagName(value) {
|
|
106
|
+
return text(value, "引用标签名称", 32);
|
|
107
|
+
}
|
|
108
|
+
function citationExpectedRevision(value) {
|
|
109
|
+
if (!Number.isInteger(value) || Number(value) < 1) {
|
|
110
|
+
throw new ValidationError("引用版本格式不正确");
|
|
111
|
+
}
|
|
112
|
+
return Number(value);
|
|
113
|
+
}
|
|
114
|
+
function citationNullableText(value, name, max) {
|
|
115
|
+
if (value === undefined || value === null || value === "")
|
|
116
|
+
return null;
|
|
117
|
+
if (typeof value !== "string" || value.length > max)
|
|
118
|
+
throw new ValidationError(`${name}格式不正确`);
|
|
119
|
+
const trimmed = value.trim();
|
|
120
|
+
return trimmed || null;
|
|
121
|
+
}
|
|
122
|
+
function citationInput(value) {
|
|
123
|
+
if (typeof value !== "object" || value === null)
|
|
124
|
+
throw new ValidationError("引用条目格式不正确");
|
|
125
|
+
const body = value;
|
|
126
|
+
if (typeof body.bibtex !== "string" || !body.bibtex.trim()) {
|
|
127
|
+
throw new ValidationError("引用条目不能为空");
|
|
128
|
+
}
|
|
129
|
+
if (Buffer.byteLength(body.bibtex, "utf8") > MAX_CITATION_BIBTEX_BYTES) {
|
|
130
|
+
throw Object.assign(new Error("引用条目过大"), { statusCode: 413, code: "CITATION_TOO_LARGE" });
|
|
131
|
+
}
|
|
132
|
+
if (typeof body.citationKey !== "string" || !body.citationKey.trim() || body.citationKey.length > 512) {
|
|
133
|
+
throw new ValidationError("引用 key 格式不正确");
|
|
134
|
+
}
|
|
135
|
+
if (typeof body.entryType !== "string" || !body.entryType.trim() || body.entryType.length > 128) {
|
|
136
|
+
throw new ValidationError("引用类型格式不正确");
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
bibtex: body.bibtex.trim(),
|
|
140
|
+
citationKey: body.citationKey.trim(),
|
|
141
|
+
entryType: body.entryType.trim(),
|
|
142
|
+
title: citationNullableText(body.title, "引用标题", 2048),
|
|
143
|
+
authors: citationNullableText(body.authors, "引用作者", 2048),
|
|
144
|
+
year: citationNullableText(body.year, "引用年份", 128)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
55
147
|
const tagColors = ["red", "orange", "yellow", "green", "blue", "purple", "gray"];
|
|
56
148
|
function tagsForProject(db, projectId, userId) {
|
|
57
149
|
return db.prepare(`SELECT tag.id, tag.name, tag.color
|
|
@@ -481,6 +573,227 @@ export async function buildApp(config, db, options = {}) {
|
|
|
481
573
|
.run(user.id, digestToken(request.cookies.texlite_session ?? ""));
|
|
482
574
|
return { ok: true };
|
|
483
575
|
});
|
|
576
|
+
app.get("/api/citations", async (request, reply) => {
|
|
577
|
+
const user = requireUser(request, reply, db);
|
|
578
|
+
if (!user)
|
|
579
|
+
return;
|
|
580
|
+
const query = request.query;
|
|
581
|
+
const search = typeof query.q === "string" ? query.q.trim() : "";
|
|
582
|
+
const tagId = typeof query.tag === "string" ? query.tag.trim() : "";
|
|
583
|
+
const requestedPage = Number.parseInt(query.page ?? "1", 10);
|
|
584
|
+
const requestedPageSize = Number.parseInt(query.pageSize ?? query.limit ?? "60", 10);
|
|
585
|
+
const pageSize = Math.min(200, Math.max(1, Number.isFinite(requestedPageSize) ? requestedPageSize : 60));
|
|
586
|
+
const where = ["entry.user_id = ?"];
|
|
587
|
+
const params = [user.id];
|
|
588
|
+
if (search) {
|
|
589
|
+
where.push(`(
|
|
590
|
+
citation_key LIKE ? ESCAPE '\\' OR entry_type LIKE ? ESCAPE '\\'
|
|
591
|
+
OR COALESCE(title, '') LIKE ? ESCAPE '\\'
|
|
592
|
+
OR COALESCE(authors, '') LIKE ? ESCAPE '\\'
|
|
593
|
+
OR COALESCE(year, '') LIKE ? ESCAPE '\\'
|
|
594
|
+
)`);
|
|
595
|
+
params.push(...Array.from({ length: 5 }, () => `%${escapeLikePattern(search)}%`));
|
|
596
|
+
}
|
|
597
|
+
if (tagId) {
|
|
598
|
+
where.push(`EXISTS (SELECT 1 FROM citation_library_entry_tags filter_link
|
|
599
|
+
JOIN citation_library_tags filter_tag ON filter_tag.id = filter_link.tag_id
|
|
600
|
+
WHERE filter_link.entry_id = entry.id AND filter_link.tag_id = ? AND filter_tag.user_id = ?)`);
|
|
601
|
+
params.push(tagId, user.id);
|
|
602
|
+
}
|
|
603
|
+
const countRow = db.prepare(`SELECT COUNT(*) AS count
|
|
604
|
+
FROM citation_library_entries entry
|
|
605
|
+
WHERE ${where.join(" AND ")}`).get(...params);
|
|
606
|
+
const total = Number(countRow.count) || 0;
|
|
607
|
+
const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0;
|
|
608
|
+
const page = totalPages > 0 ? Math.min(Math.max(1, Number.isFinite(requestedPage) ? requestedPage : 1), totalPages) : 1;
|
|
609
|
+
const offset = (page - 1) * pageSize;
|
|
610
|
+
params.push(pageSize, offset);
|
|
611
|
+
const rows = db.prepare(`SELECT entry.*, owner.username AS owner_username, owner.display_name AS owner_display_name
|
|
612
|
+
FROM citation_library_entries entry
|
|
613
|
+
JOIN users owner ON owner.id = entry.user_id
|
|
614
|
+
WHERE ${where.join(" AND ")} ORDER BY entry.updated_at DESC, entry.citation_key COLLATE NOCASE LIMIT ? OFFSET ?`).all(...params);
|
|
615
|
+
const tags = citationTagsForEntries(db, rows.map((row) => row.id));
|
|
616
|
+
return {
|
|
617
|
+
entries: rows.map((row) => citationJson(row, tags.get(row.id) ?? [])),
|
|
618
|
+
pagination: { page, pageSize, total, totalPages }
|
|
619
|
+
};
|
|
620
|
+
});
|
|
621
|
+
app.get("/api/citations/tags", async (request, reply) => {
|
|
622
|
+
const user = requireUser(request, reply, db);
|
|
623
|
+
if (!user)
|
|
624
|
+
return;
|
|
625
|
+
const tags = db.prepare(`SELECT tag.id, tag.name, tag.color, tag.user_id AS owner_id
|
|
626
|
+
FROM citation_library_tags tag
|
|
627
|
+
WHERE tag.user_id = ?
|
|
628
|
+
ORDER BY tag.name COLLATE NOCASE`).all(user.id);
|
|
629
|
+
return { tags: tags.map((tag) => ({ id: tag.id, name: tag.name, color: tag.color, ownerId: tag.owner_id })) };
|
|
630
|
+
});
|
|
631
|
+
app.post("/api/citations/tags", async (request, reply) => {
|
|
632
|
+
const user = requireUser(request, reply, db);
|
|
633
|
+
if (!user)
|
|
634
|
+
return;
|
|
635
|
+
const body = request.body;
|
|
636
|
+
const name = citationTagName(body?.name);
|
|
637
|
+
const color = tagColors.includes(body?.color) ? body?.color : "gray";
|
|
638
|
+
const existing = db.prepare("SELECT id, name, color, user_id FROM citation_library_tags WHERE user_id = ? AND name = ? COLLATE NOCASE").get(user.id, name);
|
|
639
|
+
if (existing)
|
|
640
|
+
return { tag: { id: existing.id, name: existing.name, color: existing.color, ownerId: existing.user_id }, created: false };
|
|
641
|
+
const tag = { id: randomUUID(), name, color, ownerId: user.id };
|
|
642
|
+
db.prepare("INSERT INTO citation_library_tags (id, user_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
643
|
+
.run(tag.id, user.id, tag.name, tag.color, now());
|
|
644
|
+
return reply.code(201).send({ tag, created: true });
|
|
645
|
+
});
|
|
646
|
+
app.delete("/api/citations/tags/:tagId", async (request, reply) => {
|
|
647
|
+
const user = requireUser(request, reply, db);
|
|
648
|
+
if (!user)
|
|
649
|
+
return;
|
|
650
|
+
const { tagId } = request.params;
|
|
651
|
+
const result = db.prepare("DELETE FROM citation_library_tags WHERE id = ? AND user_id = ?").run(tagId, user.id);
|
|
652
|
+
if (!result.changes)
|
|
653
|
+
return apiError(reply, 404, "CITATION_TAG_NOT_FOUND", "引用标签不存在");
|
|
654
|
+
return { ok: true };
|
|
655
|
+
});
|
|
656
|
+
// Keep the old settings endpoint explicit so stale clients cannot accidentally
|
|
657
|
+
// reinterpret "settings" as a citation id. Citation libraries are always private.
|
|
658
|
+
app.patch("/api/citations/settings", async (request, reply) => {
|
|
659
|
+
if (!requireUser(request, reply, db))
|
|
660
|
+
return;
|
|
661
|
+
return apiError(reply, 403, "CITATION_LIBRARY_PRIVATE", "引用库始终为私有,仅当前用户可访问");
|
|
662
|
+
});
|
|
663
|
+
app.post("/api/citations/lookup", async (request, reply) => {
|
|
664
|
+
const user = requireUser(request, reply, db);
|
|
665
|
+
if (!user)
|
|
666
|
+
return;
|
|
667
|
+
const body = request.body;
|
|
668
|
+
if (!Array.isArray(body?.keys) || body.keys.length > 5000
|
|
669
|
+
|| body.keys.some((key) => typeof key !== "string" || !key.trim() || key.length > 512)) {
|
|
670
|
+
throw new ValidationError("引用 key 列表格式不正确");
|
|
671
|
+
}
|
|
672
|
+
const keys = [...new Map(body.keys.map((key) => [key.trim().toLowerCase(), key.trim()])).values()];
|
|
673
|
+
const matches = [];
|
|
674
|
+
for (let offset = 0; offset < keys.length; offset += 500) {
|
|
675
|
+
const chunk = keys.slice(offset, offset + 500);
|
|
676
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
677
|
+
matches.push(...db.prepare(`SELECT id, citation_key, revision FROM citation_library_entries
|
|
678
|
+
WHERE user_id = ? AND citation_key COLLATE NOCASE IN (${placeholders})`)
|
|
679
|
+
.all(user.id, ...chunk));
|
|
680
|
+
}
|
|
681
|
+
return { matches: matches.map((match) => ({ id: match.id, citationKey: match.citation_key, revision: match.revision })) };
|
|
682
|
+
});
|
|
683
|
+
app.post("/api/citations", async (request, reply) => {
|
|
684
|
+
const user = requireUser(request, reply, db);
|
|
685
|
+
if (!user)
|
|
686
|
+
return;
|
|
687
|
+
const body = request.body;
|
|
688
|
+
const citation = citationInput(body);
|
|
689
|
+
const tagIds = citationTagIds(db, user.id, body?.tagIds);
|
|
690
|
+
const overwrite = body?.overwrite === true;
|
|
691
|
+
const existing = db.prepare("SELECT id, revision FROM citation_library_entries WHERE user_id = ? AND citation_key = ? COLLATE NOCASE")
|
|
692
|
+
.get(user.id, citation.citationKey);
|
|
693
|
+
if (existing && !overwrite)
|
|
694
|
+
return apiError(reply, 409, "CITATION_KEY_EXISTS", "引用 key 已经存在");
|
|
695
|
+
const expectedRevision = existing ? citationExpectedRevision(body?.expectedRevision) : null;
|
|
696
|
+
if (existing && existing.revision !== expectedRevision) {
|
|
697
|
+
return apiError(reply, 409, "CITATION_CONFLICT", "引用已在其他位置发生修改,请刷新后重试");
|
|
698
|
+
}
|
|
699
|
+
const id = existing?.id ?? randomUUID();
|
|
700
|
+
const timestamp = now();
|
|
701
|
+
db.transaction(() => {
|
|
702
|
+
if (existing) {
|
|
703
|
+
const result = db.prepare(`UPDATE citation_library_entries SET citation_key = ?, entry_type = ?, bibtex = ?,
|
|
704
|
+
title = ?, authors = ?, year = ?, revision = revision + 1, updated_at = ?
|
|
705
|
+
WHERE id = ? AND user_id = ? AND revision = ?`)
|
|
706
|
+
.run(citation.citationKey, citation.entryType, citation.bibtex, citation.title, citation.authors, citation.year, timestamp, id, user.id, expectedRevision);
|
|
707
|
+
if (!result.changes)
|
|
708
|
+
throw Object.assign(new Error("引用已在其他位置发生修改,请刷新后重试"), { statusCode: 409, code: "CITATION_CONFLICT" });
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
db.prepare(`INSERT INTO citation_library_entries
|
|
712
|
+
(id, user_id, citation_key, entry_type, bibtex, title, authors, year, revision, created_at, updated_at)
|
|
713
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`)
|
|
714
|
+
.run(id, user.id, citation.citationKey, citation.entryType, citation.bibtex, citation.title, citation.authors, citation.year, timestamp, timestamp);
|
|
715
|
+
}
|
|
716
|
+
if (tagIds !== null) {
|
|
717
|
+
db.prepare("DELETE FROM citation_library_entry_tags WHERE entry_id = ?").run(id);
|
|
718
|
+
for (const tagId of tagIds)
|
|
719
|
+
db.prepare("INSERT INTO citation_library_entry_tags (entry_id, tag_id, created_at) VALUES (?, ?, ?)").run(id, tagId, timestamp);
|
|
720
|
+
}
|
|
721
|
+
})();
|
|
722
|
+
const row = db.prepare("SELECT * FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
723
|
+
.get(id, user.id);
|
|
724
|
+
const tags = citationTagsForEntries(db, [id]);
|
|
725
|
+
return reply.code(existing ? 200 : 201).send({ entry: citationJson(row, tags.get(id) ?? []), updated: Boolean(existing) });
|
|
726
|
+
});
|
|
727
|
+
app.patch("/api/citations/:citationId/tags", async (request, reply) => {
|
|
728
|
+
const user = requireUser(request, reply, db);
|
|
729
|
+
if (!user)
|
|
730
|
+
return;
|
|
731
|
+
const { citationId } = request.params;
|
|
732
|
+
const existing = db.prepare("SELECT id FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
733
|
+
.get(citationId, user.id);
|
|
734
|
+
if (!existing)
|
|
735
|
+
return apiError(reply, 404, "CITATION_NOT_FOUND", "引用条目不存在");
|
|
736
|
+
const body = request.body;
|
|
737
|
+
const tagIds = citationTagIds(db, user.id, body?.tagIds);
|
|
738
|
+
if (tagIds === null)
|
|
739
|
+
throw new ValidationError("引用标签格式不正确");
|
|
740
|
+
const expectedRevision = citationExpectedRevision(body?.expectedRevision);
|
|
741
|
+
const timestamp = now();
|
|
742
|
+
db.transaction(() => {
|
|
743
|
+
const result = db.prepare(`UPDATE citation_library_entries SET revision = revision + 1, updated_at = ?
|
|
744
|
+
WHERE id = ? AND user_id = ? AND revision = ?`).run(timestamp, citationId, user.id, expectedRevision);
|
|
745
|
+
if (!result.changes) {
|
|
746
|
+
const exists = db.prepare("SELECT id FROM citation_library_entries WHERE id = ? AND user_id = ?").get(citationId, user.id);
|
|
747
|
+
if (!exists)
|
|
748
|
+
throw Object.assign(new Error("引用条目不存在"), { statusCode: 404, code: "CITATION_NOT_FOUND" });
|
|
749
|
+
throw Object.assign(new Error("引用已在其他位置发生修改,请刷新后重试"), { statusCode: 409, code: "CITATION_CONFLICT" });
|
|
750
|
+
}
|
|
751
|
+
db.prepare("DELETE FROM citation_library_entry_tags WHERE entry_id = ?").run(citationId);
|
|
752
|
+
for (const tagId of tagIds)
|
|
753
|
+
db.prepare("INSERT INTO citation_library_entry_tags (entry_id, tag_id, created_at) VALUES (?, ?, ?)").run(citationId, tagId, timestamp);
|
|
754
|
+
})();
|
|
755
|
+
const row = db.prepare("SELECT * FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
756
|
+
.get(citationId, user.id);
|
|
757
|
+
const tags = citationTagsForEntries(db, [citationId]);
|
|
758
|
+
return { entry: citationJson(row, tags.get(citationId) ?? []) };
|
|
759
|
+
});
|
|
760
|
+
app.patch("/api/citations/:citationId", async (request, reply) => {
|
|
761
|
+
const user = requireUser(request, reply, db);
|
|
762
|
+
if (!user)
|
|
763
|
+
return;
|
|
764
|
+
const { citationId } = request.params;
|
|
765
|
+
const existing = db.prepare("SELECT id FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
766
|
+
.get(citationId, user.id);
|
|
767
|
+
if (!existing)
|
|
768
|
+
return apiError(reply, 404, "CITATION_NOT_FOUND", "引用条目不存在");
|
|
769
|
+
const body = request.body;
|
|
770
|
+
const citation = citationInput(body);
|
|
771
|
+
const expectedRevision = citationExpectedRevision(body?.expectedRevision);
|
|
772
|
+
const duplicate = db.prepare("SELECT id FROM citation_library_entries WHERE user_id = ? AND citation_key = ? COLLATE NOCASE AND id != ?")
|
|
773
|
+
.get(user.id, citation.citationKey, citationId);
|
|
774
|
+
if (duplicate)
|
|
775
|
+
return apiError(reply, 409, "CITATION_KEY_EXISTS", "引用 key 已经存在");
|
|
776
|
+
const result = db.prepare(`UPDATE citation_library_entries SET citation_key = ?, entry_type = ?, bibtex = ?,
|
|
777
|
+
title = ?, authors = ?, year = ?, revision = revision + 1, updated_at = ?
|
|
778
|
+
WHERE id = ? AND user_id = ? AND revision = ?`)
|
|
779
|
+
.run(citation.citationKey, citation.entryType, citation.bibtex, citation.title, citation.authors, citation.year, now(), citationId, user.id, expectedRevision);
|
|
780
|
+
if (!result.changes)
|
|
781
|
+
return apiError(reply, 409, "CITATION_CONFLICT", "引用已在其他位置发生修改,请刷新后重试");
|
|
782
|
+
const updated = db.prepare("SELECT * FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
783
|
+
.get(citationId, user.id);
|
|
784
|
+
const tags = citationTagsForEntries(db, [citationId]);
|
|
785
|
+
return { entry: citationJson(updated, tags.get(citationId) ?? []) };
|
|
786
|
+
});
|
|
787
|
+
app.delete("/api/citations/:citationId", async (request, reply) => {
|
|
788
|
+
const user = requireUser(request, reply, db);
|
|
789
|
+
if (!user)
|
|
790
|
+
return;
|
|
791
|
+
const { citationId } = request.params;
|
|
792
|
+
const result = db.prepare("DELETE FROM citation_library_entries WHERE id = ? AND user_id = ?").run(citationId, user.id);
|
|
793
|
+
if (!result.changes)
|
|
794
|
+
return apiError(reply, 404, "CITATION_NOT_FOUND", "引用条目不存在");
|
|
795
|
+
return { ok: true };
|
|
796
|
+
});
|
|
484
797
|
app.get("/api/admin/users", async (request, reply) => {
|
|
485
798
|
if (!requireAdmin(request, reply, db))
|
|
486
799
|
return;
|
|
@@ -1367,8 +1680,8 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1367
1680
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
|
|
1368
1681
|
const body = request.body;
|
|
1369
1682
|
const filePath = safeRelativePath(typeof body?.path === "string" ? body.path : "");
|
|
1370
|
-
if (!/\.(?:tex|
|
|
1371
|
-
return apiError(reply, 400, "FORMAT_FILE_UNSUPPORTED", "只有 .tex、.
|
|
1683
|
+
if (!/\.(?:tex|cls|sty)$/i.test(filePath)) {
|
|
1684
|
+
return apiError(reply, 400, "FORMAT_FILE_UNSUPPORTED", "只有 .tex、.cls 和 .sty 文件支持宿主机格式化", { path: filePath });
|
|
1372
1685
|
}
|
|
1373
1686
|
if (typeof body?.source !== "string")
|
|
1374
1687
|
return apiError(reply, 400, "FORMAT_SOURCE_INVALID", "待格式化内容格式不正确");
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small, dependency-free BibTeX entry parser used by the personal citation
|
|
3
|
+
* library. It deliberately preserves the original entry text so importing a
|
|
4
|
+
* reference never rewrites formatting chosen by the library owner.
|
|
5
|
+
*/
|
|
6
|
+
const ignoredEntryTypes = new Set(["comment", "string", "preamble"]);
|
|
7
|
+
export const MAX_CITATION_BIBTEX_BYTES = 512 * 1024;
|
|
8
|
+
export function parseBibEntries(source) {
|
|
9
|
+
const entries = [];
|
|
10
|
+
let searchFrom = 0;
|
|
11
|
+
while (searchFrom < source.length) {
|
|
12
|
+
const at = source.indexOf("@", searchFrom);
|
|
13
|
+
if (at < 0)
|
|
14
|
+
break;
|
|
15
|
+
searchFrom = at + 1;
|
|
16
|
+
const lineStart = source.lastIndexOf("\n", at - 1) + 1;
|
|
17
|
+
if (isCommented(source, lineStart, at))
|
|
18
|
+
continue;
|
|
19
|
+
const header = source.slice(at).match(/^@([A-Za-z][A-Za-z0-9_-]*)\s*([\{\(])/);
|
|
20
|
+
if (!header || header.index === undefined)
|
|
21
|
+
continue;
|
|
22
|
+
const opening = at + header[0].length - 1;
|
|
23
|
+
const closing = matchingDelimiter(source, opening, header[2] === "{" ? "}" : ")");
|
|
24
|
+
if (closing < 0)
|
|
25
|
+
continue;
|
|
26
|
+
const raw = source.slice(at, closing + 1).trim();
|
|
27
|
+
const parsed = parseSingleBibEntry(raw);
|
|
28
|
+
if (parsed && !ignoredEntryTypes.has(parsed.entryType.toLowerCase()))
|
|
29
|
+
entries.push(parsed);
|
|
30
|
+
searchFrom = closing + 1;
|
|
31
|
+
}
|
|
32
|
+
return entries;
|
|
33
|
+
}
|
|
34
|
+
function isCommented(source, lineStart, end) {
|
|
35
|
+
let escaped = false;
|
|
36
|
+
for (let index = lineStart; index < end; index += 1) {
|
|
37
|
+
const character = source[index];
|
|
38
|
+
if (character === "%" && !escaped)
|
|
39
|
+
return true;
|
|
40
|
+
if (character === "\\")
|
|
41
|
+
escaped = !escaped;
|
|
42
|
+
else
|
|
43
|
+
escaped = false;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
export function parseSingleBibEntry(source) {
|
|
48
|
+
const bibtex = source.trim();
|
|
49
|
+
if (!bibtex || new TextEncoder().encode(bibtex).length > MAX_CITATION_BIBTEX_BYTES)
|
|
50
|
+
return null;
|
|
51
|
+
const header = bibtex.match(/^@([A-Za-z][A-Za-z0-9_-]*)\s*([\{\(])\s*([^,\s\}\)]+)\s*,/s);
|
|
52
|
+
if (!header)
|
|
53
|
+
return null;
|
|
54
|
+
const opening = bibtex.indexOf(header[2], header.index ?? 0);
|
|
55
|
+
const closing = matchingDelimiter(bibtex, opening, header[2] === "{" ? "}" : ")");
|
|
56
|
+
if (closing !== bibtex.length - 1)
|
|
57
|
+
return null;
|
|
58
|
+
const entryType = header[1];
|
|
59
|
+
if (ignoredEntryTypes.has(entryType.toLowerCase()))
|
|
60
|
+
return null;
|
|
61
|
+
const fields = parseFields(bibtex.slice(header[0].length, -1));
|
|
62
|
+
if (!fields)
|
|
63
|
+
return null;
|
|
64
|
+
return {
|
|
65
|
+
citationKey: header[3],
|
|
66
|
+
entryType,
|
|
67
|
+
bibtex,
|
|
68
|
+
title: fields.title ?? null,
|
|
69
|
+
authors: fields.author ?? null,
|
|
70
|
+
year: fields.year ?? null
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function matchingDelimiter(source, opening, closingCharacter) {
|
|
74
|
+
let outerDepth = 0;
|
|
75
|
+
let braceDepth = 0;
|
|
76
|
+
let quoted = false;
|
|
77
|
+
let escaped = false;
|
|
78
|
+
for (let index = opening; index < source.length; index += 1) {
|
|
79
|
+
const character = source[index];
|
|
80
|
+
if (escaped) {
|
|
81
|
+
escaped = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (character === "\\") {
|
|
85
|
+
escaped = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (closingCharacter === "}") {
|
|
89
|
+
if (character === '"' && outerDepth === 1)
|
|
90
|
+
quoted = !quoted;
|
|
91
|
+
if (character === "{")
|
|
92
|
+
outerDepth += 1;
|
|
93
|
+
else if (character === "}" && --outerDepth === 0)
|
|
94
|
+
return index;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (character === "{" && !quoted) {
|
|
98
|
+
braceDepth += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (character === "}" && !quoted && braceDepth > 0) {
|
|
102
|
+
braceDepth -= 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (braceDepth > 0)
|
|
106
|
+
continue;
|
|
107
|
+
if (character === '"') {
|
|
108
|
+
quoted = !quoted;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (quoted)
|
|
112
|
+
continue;
|
|
113
|
+
if (character === "(")
|
|
114
|
+
outerDepth += 1;
|
|
115
|
+
else if (character === ")" && --outerDepth === 0)
|
|
116
|
+
return index;
|
|
117
|
+
}
|
|
118
|
+
return -1;
|
|
119
|
+
}
|
|
120
|
+
function parseFields(body) {
|
|
121
|
+
const fields = {};
|
|
122
|
+
for (const segment of splitTopLevel(body, ",")) {
|
|
123
|
+
if (!segment.trim())
|
|
124
|
+
continue;
|
|
125
|
+
const match = segment.match(/^\s*([A-Za-z][A-Za-z0-9_-]*)\s*=\s*([\s\S]*)$/);
|
|
126
|
+
if (!match || !match[2].trim())
|
|
127
|
+
return null;
|
|
128
|
+
const value = cleanFieldValue(match[2]);
|
|
129
|
+
if (value)
|
|
130
|
+
fields[match[1].toLowerCase()] = value;
|
|
131
|
+
}
|
|
132
|
+
return fields;
|
|
133
|
+
}
|
|
134
|
+
function splitTopLevel(source, separator) {
|
|
135
|
+
const result = [];
|
|
136
|
+
let start = 0;
|
|
137
|
+
let depth = 0;
|
|
138
|
+
let quoted = false;
|
|
139
|
+
let escaped = false;
|
|
140
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
141
|
+
const character = source[index];
|
|
142
|
+
if (escaped) {
|
|
143
|
+
escaped = false;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (character === "\\") {
|
|
147
|
+
escaped = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (character === '"') {
|
|
151
|
+
quoted = !quoted;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (quoted)
|
|
155
|
+
continue;
|
|
156
|
+
if (character === "{")
|
|
157
|
+
depth += 1;
|
|
158
|
+
else if (character === "}")
|
|
159
|
+
depth = Math.max(0, depth - 1);
|
|
160
|
+
else if (character === separator && depth === 0) {
|
|
161
|
+
result.push(source.slice(start, index));
|
|
162
|
+
start = index + 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
result.push(source.slice(start));
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
function cleanFieldValue(value) {
|
|
169
|
+
let result = value.trim();
|
|
170
|
+
if ((result.startsWith("{") && result.endsWith("}")) || (result.startsWith('"') && result.endsWith('"'))) {
|
|
171
|
+
result = result.slice(1, -1);
|
|
172
|
+
}
|
|
173
|
+
return result.replace(/\s+/g, " ").trim();
|
|
174
|
+
}
|
package/dist/server/db.js
CHANGED
|
@@ -179,6 +179,39 @@ function migrate(db) {
|
|
|
179
179
|
);
|
|
180
180
|
CREATE INDEX IF NOT EXISTS project_dictionary_words_project_id ON project_dictionary_words(project_id);
|
|
181
181
|
|
|
182
|
+
CREATE TABLE IF NOT EXISTS citation_library_entries (
|
|
183
|
+
id TEXT PRIMARY KEY,
|
|
184
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
185
|
+
citation_key TEXT NOT NULL COLLATE NOCASE,
|
|
186
|
+
entry_type TEXT NOT NULL,
|
|
187
|
+
bibtex TEXT NOT NULL,
|
|
188
|
+
title TEXT,
|
|
189
|
+
authors TEXT,
|
|
190
|
+
year TEXT,
|
|
191
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
192
|
+
created_at TEXT NOT NULL,
|
|
193
|
+
updated_at TEXT NOT NULL,
|
|
194
|
+
UNIQUE (user_id, citation_key)
|
|
195
|
+
);
|
|
196
|
+
CREATE INDEX IF NOT EXISTS citation_library_entries_user_updated
|
|
197
|
+
ON citation_library_entries(user_id, updated_at DESC);
|
|
198
|
+
|
|
199
|
+
CREATE TABLE IF NOT EXISTS citation_library_tags (
|
|
200
|
+
id TEXT PRIMARY KEY,
|
|
201
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
202
|
+
name TEXT NOT NULL COLLATE NOCASE,
|
|
203
|
+
color TEXT NOT NULL CHECK (color IN ('red', 'orange', 'yellow', 'green', 'blue', 'purple', 'gray')),
|
|
204
|
+
created_at TEXT NOT NULL,
|
|
205
|
+
UNIQUE (user_id, name)
|
|
206
|
+
);
|
|
207
|
+
CREATE TABLE IF NOT EXISTS citation_library_entry_tags (
|
|
208
|
+
entry_id TEXT NOT NULL REFERENCES citation_library_entries(id) ON DELETE CASCADE,
|
|
209
|
+
tag_id TEXT NOT NULL REFERENCES citation_library_tags(id) ON DELETE CASCADE,
|
|
210
|
+
created_at TEXT NOT NULL,
|
|
211
|
+
PRIMARY KEY (entry_id, tag_id)
|
|
212
|
+
);
|
|
213
|
+
CREATE INDEX IF NOT EXISTS citation_library_entry_tags_tag_id ON citation_library_entry_tags(tag_id);
|
|
214
|
+
|
|
182
215
|
CREATE TABLE IF NOT EXISTS project_git_settings (
|
|
183
216
|
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
|
|
184
217
|
token_ciphertext TEXT,
|
|
@@ -224,6 +257,10 @@ function migrate(db) {
|
|
|
224
257
|
if (!compileRunColumns.some((column) => column.name === "main_file")) {
|
|
225
258
|
db.exec("ALTER TABLE compile_runs ADD COLUMN main_file TEXT NOT NULL DEFAULT ''");
|
|
226
259
|
}
|
|
260
|
+
const citationColumns = db.prepare("PRAGMA table_info(citation_library_entries)").all();
|
|
261
|
+
if (!citationColumns.some((column) => column.name === "revision")) {
|
|
262
|
+
db.exec("ALTER TABLE citation_library_entries ADD COLUMN revision INTEGER NOT NULL DEFAULT 1");
|
|
263
|
+
}
|
|
227
264
|
db.exec(`
|
|
228
265
|
UPDATE users SET can_create_projects = 1 WHERE role = 'admin';
|
|
229
266
|
UPDATE projects SET last_modified_by = owner_id WHERE last_modified_by IS NULL;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "texlite",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "A lightweight local-first collaborative LaTeX workspace",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": {
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
70
70
|
"@replit/codemirror-vim": "^6.4.0",
|
|
71
71
|
"better-sqlite3": "^13.0.2",
|
|
72
|
+
"bibtex-tidy": "1.14.0",
|
|
72
73
|
"diff-match-patch": "^1.0.5",
|
|
73
74
|
"fastify": "^5.4.0",
|
|
74
75
|
"harper.js": "^2.7.0",
|
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
import{u as ye,r as n,j as e}from"./react-CsO_xvbl.js";import{i as h,M as H,L as p,G as q,C as ie,j as ne,T as ke,R as be,k as l}from"./index-DJzyhcDQ.js";import{M as ve,P as Ce,a as Ne}from"./plus-BmrCa7ds.js";const we=h("CloudUpload",[["path",{d:"M12 13v8",key:"1l5pq0"}],["path",{d:"M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242",key:"1pljnt"}],["path",{d:"m8 17 4-4 4 4",key:"1quai1"}]]);const ae=h("ExternalLink",[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]]);const G=h("Eye",[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);const oe=h("GitCommitHorizontal",[["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}],["line",{x1:"3",x2:"9",y1:"12",y2:"12",key:"1dyftd"}],["line",{x1:"15",x2:"21",y1:"12",y2:"12",key:"oup4p8"}]]);const W=h("Github",[["path",{d:"M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4",key:"tonef"}],["path",{d:"M9 18c-4.51 2-5-2-7-2",key:"9comsn"}]]);const ze=h("KeyRound",[["path",{d:"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z",key:"1s6t7t"}],["circle",{cx:"16.5",cy:"7.5",r:".5",fill:"currentColor",key:"w0ekpg"}]]);const Se=h("Minimize2",[["polyline",{points:"4 14 10 14 10 20",key:"11kfnr"}],["polyline",{points:"20 10 14 10 14 4",key:"rlmsce"}],["line",{x1:"14",x2:"21",y1:"10",y2:"3",key:"o5lafz"}],["line",{x1:"3",x2:"10",y1:"21",y2:"14",key:"1atl0r"}]]);const Ee=h("RefreshCcw",[["path",{d:"M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"14sxne"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}],["path",{d:"M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16",key:"1hlbsb"}],["path",{d:"M16 16h5v5",key:"ccwih5"}]]);const Te=h("Undo2",[["path",{d:"M9 14 4 9l5-5",key:"102s5s"}],["path",{d:"M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11",key:"f3b9sd"}]]);function Ue({open:g,project:o,onOpenChange:y,onBeforeMutation:r}){const{t:s,i18n:E}=ye(),[i,m]=n.useState(null),[J,V]=n.useState([]),[T,k]=n.useState(""),[F,K]=n.useState(()=>re(o.name)),[_,ce]=n.useState(!0),[D,Z]=n.useState(""),[b,M]=n.useState(null),[f,j]=n.useState(null),[R,U]=n.useState(!1),[le,v]=n.useState(!1),[$,L]=n.useState(!0),[P,A]=n.useState(!0),[a,C]=n.useState(""),[Q,N]=n.useState(""),[X,B]=n.useState(""),[u,x]=n.useState(!1),[w,Y]=n.useState(10),O=n.useRef(null),z=async(t=!1)=>{const c=await l(`/api/projects/${o.id}/git`);if(m(c.status),t&&(L(!c.status.tokenConfigured),A(!c.status.remoteUrl)),c.status.initialized&&c.status.latestCommit){const S=await l(`/api/projects/${o.id}/git/history`);V(S.commits)}else V([])};n.useEffect(()=>{if(!g){document.fullscreenElement&&document.exitFullscreen().catch(()=>{}),x(!1),k("");return}N(""),B(""),M(null),x(!1),K(re(o.name)),C("load"),r().then(()=>z(!0)).catch(t=>N(t instanceof Error?t.message:s("errors.generic"))).finally(()=>C(""))},[g,o.id]),n.useEffect(()=>{const t=()=>x(document.fullscreenElement===O.current);return document.addEventListener("fullscreenchange",t),()=>document.removeEventListener("fullscreenchange",t)},[]),n.useEffect(()=>{if(!u)return;const t=c=>{c.key==="Escape"&&!document.fullscreenElement&&x(!1)};return window.addEventListener("keydown",t),()=>window.removeEventListener("keydown",t)},[u]);const d=async(t,c,S,ee)=>{C(t),N(""),B("");try{await c(),S&&B(S)}catch(se){const te=se instanceof Error?se.message:s("errors.generic");N(ee?`${ee}: ${te}`:te)}finally{C("")}},de=()=>d("token",async()=>{const t=await l(`/api/projects/${o.id}/git/token`,{method:"PUT",body:JSON.stringify({token:T})});m(t.status),k(""),L(!1)},s("git.tokenSaved")),he=()=>d("remove-token",async()=>{const t=await l(`/api/projects/${o.id}/git/token`,{method:"DELETE"});m(t.status),k("")},s("git.tokenRemoved")),ge=()=>d("repository",async()=>{const t=await l(`/api/projects/${o.id}/git/repository`,{method:"POST",body:JSON.stringify({name:F,private:_})});m(t.status),A(!1)},s("git.repositoryCreated")),ue=()=>d("commit",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));const t=await l(`/api/projects/${o.id}/git/commit`,{method:"POST",body:JSON.stringify({message:D})});m(t.status),Z(""),M(null),await z()},s("git.committed")),me=()=>d("push",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));const t=await l(`/api/projects/${o.id}/git/push`,{method:"POST"});m(t.status),await z()},s("git.pushed"),s("git.pushFailed")),I=t=>d("diff",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));const c=t?`?revision=${encodeURIComponent(t)}`:"";M(await l(`/api/projects/${o.id}/git/diff${c}`))}),fe=async()=>{if(u){document.fullscreenElement&&await document.exitFullscreen().catch(()=>{}),x(!1);return}const t=O.current;if(t?.requestFullscreen)try{await t.requestFullscreen()}catch{}x(!0)},xe=()=>{const t=f;t&&(j(null),d("checkout",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));await l(`/api/projects/${o.id}/git/checkout`,{method:"POST",body:JSON.stringify({revision:t==="branch"?null:t.sha,force:R})}),window.location.reload()}))},pe=()=>{v(!1),d("discard",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));await l(`/api/projects/${o.id}/git/discard`,{method:"POST"}),window.location.reload()})},je=a==="load"&&!i;return e.jsxs(e.Fragment,{children:[e.jsx(H,{open:g,extraWide:!0,className:u?"git-dialog-modal-fullscreen":void 0,title:s("git.title"),description:s("git.description"),onOpenChange:y,footer:e.jsx("button",{onClick:()=>y(!1),children:s("common.close")}),children:e.jsxs("div",{className:"git-dialog",children:[je&&e.jsxs("div",{className:"git-loading",children:[e.jsx(p,{className:"spin",size:22}),s("common.loading")]}),Q&&e.jsx("p",{className:"git-message error",children:Q}),X&&e.jsxs("p",{className:"git-message success",children:[e.jsx(q,{size:14}),X]}),i&&e.jsxs(e.Fragment,{children:[e.jsxs("section",{className:"git-section",children:[e.jsxs("button",{className:"git-section-toggle",type:"button","aria-expanded":$,onClick:()=>L(t=>!t),children:[e.jsxs("span",{className:"git-section-heading",children:[e.jsx(ze,{size:16}),e.jsxs("span",{children:[e.jsx("strong",{children:s("git.authentication")}),e.jsx("small",{children:i.tokenConfigured?s("git.connectedAs",{login:i.githubLogin}):s("git.authenticationHint")})]})]}),$?e.jsx(ie,{size:16}):e.jsx(ne,{size:16})]}),$&&e.jsxs("div",{className:"git-section-content",children:[e.jsxs("div",{className:"git-token-guidance",children:[e.jsx("strong",{children:s("git.recommendedAccessTitle")}),e.jsx("span",{children:s("git.recommendedAccess")})]}),i.tokenConfigured&&e.jsxs("div",{className:"git-account",children:[e.jsx(W,{size:17}),e.jsx("span",{children:s("git.connectedAs",{login:i.githubLogin})}),e.jsxs("button",{className:"danger-text",disabled:!!a,onClick:he,children:[e.jsx(ke,{size:14}),s("git.removeToken")]})]}),e.jsxs("label",{className:"form-field",children:[i.tokenConfigured?s("git.replaceToken"):s("git.token"),e.jsx("input",{type:"password",autoComplete:"new-password",spellCheck:!1,autoCapitalize:"none",autoCorrect:"off",value:T,placeholder:s("git.tokenPlaceholder"),onChange:t=>k(t.target.value)})]}),e.jsxs("div",{className:"git-inline-actions",children:[e.jsxs("a",{href:"https://github.com/settings/personal-access-tokens/new",target:"_blank",rel:"noreferrer",children:[s("git.createToken"),e.jsx(ae,{size:12})]}),e.jsxs("button",{disabled:!!a||T.trim().length<20,onClick:de,children:[a==="token"&&e.jsx(p,{className:"spin",size:13}),s("git.saveToken")]})]})]})]}),e.jsxs("section",{className:"git-section",children:[e.jsxs("button",{className:"git-section-toggle",type:"button","aria-expanded":P,onClick:()=>A(t=>!t),children:[e.jsxs("span",{className:"git-section-heading",children:[e.jsx(W,{size:16}),e.jsxs("span",{children:[e.jsx("strong",{children:s("git.repository")}),e.jsx("small",{children:i.remoteUrl?i.repositoryName??i.remoteUrl:i.initialized?s("git.localReady"):s("git.localPending")})]})]}),P?e.jsx(ie,{size:16}):e.jsx(ne,{size:16})]}),P&&e.jsx("div",{className:"git-section-content",children:i.remoteUrl?e.jsxs("div",{className:"git-repository-card",children:[e.jsxs("div",{children:[e.jsx("strong",{children:i.repositoryName??s("git.origin")}),e.jsxs("small",{children:[i.branch??s("git.detachedAt",{revision:i.latestCommit?.shortSha??"HEAD"})," · ",i.dirty?s("git.changedFiles",{count:i.changedFiles}):s("git.clean")]})]}),i.repositoryHtmlUrl&&e.jsxs("a",{href:i.repositoryHtmlUrl,target:"_blank",rel:"noreferrer",children:[e.jsx(ae,{size:14}),s("git.openGitHub")]})]}):e.jsxs(e.Fragment,{children:[e.jsxs("label",{className:"form-field",children:[s("git.repositoryName"),e.jsx("input",{value:F,onChange:t=>K(t.target.value)})]}),e.jsxs("label",{className:"checkbox-field",children:[e.jsx("input",{type:"checkbox",checked:_,onChange:t=>ce(t.target.checked)}),s("git.privateRepository")]}),e.jsxs("button",{className:"git-primary-action",disabled:!!a||!i.tokenConfigured||!F.trim(),onClick:ge,children:[a==="repository"?e.jsx(p,{className:"spin",size:14}):e.jsx(W,{size:14}),s("git.createRepository")]})]})})]}),i.initialized&&e.jsxs("section",{className:"git-section",children:[e.jsxs("div",{className:"git-section-heading",children:[e.jsx(oe,{size:16}),e.jsxs("div",{children:[e.jsx("strong",{children:s("git.commitAndPush")}),e.jsx("small",{children:i.branch?s("git.identity",{username:o.ownerUsername}):s("git.detachedHead")})]})]}),e.jsxs("label",{className:"form-field",children:[s("git.commitMessage"),e.jsx("textarea",{rows:3,value:D,placeholder:s("git.commitPlaceholder"),onChange:t=>Z(t.target.value)})]}),e.jsxs("div",{className:"git-action-row",children:[e.jsxs("button",{title:i.dirty?void 0:s("git.noChangesToCommit"),disabled:!!a||!i.branch||!i.dirty||!D.trim(),onClick:ue,children:[a==="commit"?e.jsx(p,{className:"spin",size:14}):e.jsx(oe,{size:14}),s("git.commit")]}),e.jsxs("button",{disabled:!!a||!i.branch||!i.tokenConfigured||!i.remoteUrl||!i.latestCommit,onClick:me,children:[a==="push"?e.jsx(p,{className:"spin",size:14}):e.jsx(we,{size:14}),s("git.push"),i.ahead>0&&e.jsx("span",{className:"git-count",children:i.ahead})]}),e.jsxs("button",{disabled:!!a||!i.latestCommit,onClick:()=>{I()},children:[a==="diff"?e.jsx(p,{className:"spin",size:14}):e.jsx(G,{size:14}),s("git.workingDiff")]}),e.jsxs("button",{className:"git-discard-button",title:i.restorable?s("git.discardChanges"):s("git.noChangesToDiscard"),disabled:!!a||!i.latestCommit||!i.restorable,onClick:()=>v(!0),children:[e.jsx(be,{size:14}),s("git.discardChanges")]}),!i.branch&&e.jsxs("button",{disabled:!!a,onClick:()=>{U(!1),j("branch")},children:[e.jsx(q,{size:14}),s("git.returnToBranch",{branch:i.defaultBranch})]}),e.jsxs("button",{disabled:!!a,onClick:()=>{d("refresh",z)},children:[e.jsx(Ee,{className:a==="refresh"?"spin":"",size:14}),s("git.refresh")]})]})]}),b&&e.jsxs("section",{ref:O,className:`git-section git-diff-section${u?" is-fullscreen":""}`,children:[e.jsxs("div",{className:"git-diff-heading",children:[e.jsxs("div",{className:"git-section-heading",children:[e.jsx(G,{size:16}),e.jsxs("div",{children:[e.jsx("strong",{children:s("git.diffTitle")}),e.jsxs("small",{children:[b.title,b.truncated?` · ${s("git.diffTruncated")}`:""]})]})]}),e.jsxs("div",{className:"git-diff-controls",children:[e.jsxs("div",{className:"git-diff-font-controls",role:"group","aria-label":s("git.diffFontSize"),children:[e.jsx("button",{type:"button",className:"git-diff-font-button",disabled:w<=8,title:s("git.diffFontDecrease"),"aria-label":s("git.diffFontDecrease"),onClick:()=>Y(t=>Math.max(8,t-1)),children:e.jsx(ve,{size:14})}),e.jsxs("span",{className:"git-diff-font-value","aria-live":"polite",children:[w,"px"]}),e.jsx("button",{type:"button",className:"git-diff-font-button",disabled:w>=24,title:s("git.diffFontIncrease"),"aria-label":s("git.diffFontIncrease"),onClick:()=>Y(t=>Math.min(24,t+1)),children:e.jsx(Ce,{size:14})})]}),e.jsx("button",{type:"button",className:"git-diff-fullscreen",title:s(u?"git.exitFullscreenDiff":"git.fullscreenDiff"),"aria-label":s(u?"git.exitFullscreenDiff":"git.fullscreenDiff"),onClick:()=>{fe()},children:u?e.jsx(Se,{size:15}):e.jsx(Ne,{size:15})})]})]}),e.jsx(Fe,{content:b.diff,empty:s("git.noChanges"),fontSize:w})]}),i.initialized&&e.jsxs("section",{className:"git-section",children:[e.jsxs("div",{className:"git-section-heading",children:[e.jsx(q,{size:16}),e.jsxs("div",{children:[e.jsx("strong",{children:s("git.history")}),e.jsx("small",{children:s("git.historyHint")})]})]}),e.jsxs("div",{className:"git-history",children:[J.map(t=>e.jsxs("article",{children:[e.jsx("code",{children:t.shortSha}),e.jsxs("div",{children:[e.jsx("strong",{children:t.message}),e.jsxs("small",{children:[t.authorName," · ",new Date(t.authoredAt).toLocaleString(E.resolvedLanguage)]})]}),e.jsxs("span",{children:[e.jsx("button",{title:s("git.viewCommitDiff"),disabled:!!a,onClick:()=>{I(t.sha)},children:e.jsx(G,{size:14})}),e.jsx("button",{title:s("git.checkoutVersion"),disabled:!!a,onClick:()=>{U(!1),j(t)},children:e.jsx(Te,{size:14})})]})]},t.sha)),J.length===0&&e.jsx("p",{className:"muted",children:s("git.noCommits")})]})]})]})]})}),e.jsx(H,{open:!!f,title:s(f==="branch"?"git.returnTitle":"git.checkoutTitle"),description:f==="branch"?s("git.returnDescription",{branch:i?.defaultBranch??"main"}):s("git.checkoutDescription",{revision:f?.shortSha??"",message:f?.message??""}),onOpenChange:t=>{t||j(null)},footer:e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>j(null),children:s("common.cancel")}),e.jsx("button",{className:R?"danger":"primary",onClick:xe,children:s("git.checkout")})]}),children:e.jsxs("label",{className:"git-force-checkout",children:[e.jsx("input",{type:"checkbox",checked:R,onChange:t=>U(t.target.checked)}),e.jsxs("span",{children:[e.jsx("strong",{children:s("git.forceCheckout")}),e.jsx("small",{children:s("git.forceCheckoutDescription")})]})]})}),e.jsx(H,{open:le,title:s("git.discardTitle"),description:s("git.discardDescription"),onOpenChange:v,footer:e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>v(!1),children:s("common.cancel")}),e.jsx("button",{className:"danger",onClick:pe,children:s("git.discardConfirm")})]}),children:e.jsx(e.Fragment,{})})]})}function Fe({content:g,empty:o,fontSize:y}){return g.trim()?e.jsx("pre",{className:"git-diff",style:{fontSize:`${y}px`},children:g.split(`
|
|
2
|
-
`).map((r,s)=>{const E=r.startsWith("+")&&!r.startsWith("+++")?"addition":r.startsWith("-")&&!r.startsWith("---")?"deletion":r.startsWith("@@")?"hunk":r.startsWith("diff ")?"header":"";return e.jsxs("span",{className:E,children:[r,`
|
|
3
|
-
`]},s)})}):e.jsx("div",{className:"git-diff-empty",children:o})}function re(g){return g.trim().replace(/\s+/g,"-").replace(/[^A-Za-z0-9._-]/g,"-").replace(/-+/g,"-").replace(/^[.-]+|[.-]+$/g,"").slice(0,100)||"texlite-project"}export{Ue as GitDialog};
|