papilot 0.8.3 → 0.8.4

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.
@@ -9,7 +9,7 @@
9
9
  rel="icon"
10
10
  href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232b6e5c'/%3E%3Cpath d='M4 8.5l2.5 2.5L12 5' stroke='white' stroke-width='2' fill='none'/%3E%3C/svg%3E"
11
11
  />
12
- <script type="module" crossorigin src="/assets/index-DJywe0wU.js"></script>
12
+ <script type="module" crossorigin src="/assets/index-C_KwLmXQ.js"></script>
13
13
  <link rel="stylesheet" crossorigin href="/assets/index-D3_ZFdT8.css">
14
14
  </head>
15
15
  <body>
@@ -175,7 +175,7 @@ export function ReviewApp({
175
175
  const [shortcuts, setShortcuts] = useState(readShortcuts);
176
176
  // The whole paper with the changes in place, or the changed sections only.
177
177
  // A host may fix the view in the address (Papilot's manuscript chooses its
178
- // PDF with its own switch); otherwise it follows the stored choice.
178
+ // PDF with its own switch); otherwise it opens on the stored default.
179
179
  const [pinnedView] = useState<ReviewView | null>(() => {
180
180
  const value = new URLSearchParams(location.search).get("view");
181
181
  return value === "paper" || value === "changes" ? value : null;
@@ -1144,13 +1144,9 @@ export function ReviewApp({
1144
1144
  if (article) reveal(article);
1145
1145
  });
1146
1146
  }, [paperView]);
1147
+ // The switch changes this review only; the default is a Papilot setting.
1147
1148
  function chooseView(next: ReviewView) {
1148
1149
  setView(next);
1149
- try {
1150
- localStorage.setItem(VIEW_STORAGE, next);
1151
- } catch {
1152
- /* The choice lasts for this page. */
1153
- }
1154
1150
  }
1155
1151
  let index = 0;
1156
1152
  return (
@@ -1,13 +1,15 @@
1
- // Whole paper or changed sections only. A host such as Papilot shows its own
2
- // switch: it stores the choice and tells this frame; storage events also
3
- // update other tabs.
4
- export const VIEW_STORAGE = "latex-review:view";
1
+ // Whole paper ("paper") or changed sections only ("changes"). Reviews open on
2
+ // the stored default: the changed sections, which show fastest, unless the
3
+ // whole paper was chosen in Papilot's settings. Switching views lasts for the
4
+ // open review only. A host such as Papilot shows its own switch and tells this
5
+ // frame; storage events bring a new default to reviews already open.
6
+ export const VIEW_STORAGE = "latex-review:default-view";
5
7
  export const VIEW_EVENT = "latex-review:view-changed";
6
8
  export type ReviewView = "paper" | "changes";
7
9
  export function readView(): ReviewView {
8
10
  try {
9
- return localStorage.getItem(VIEW_STORAGE) === "changes" ? "changes" : "paper";
11
+ return localStorage.getItem(VIEW_STORAGE) === "paper" ? "paper" : "changes";
10
12
  } catch {
11
- return "paper";
13
+ return "changes";
12
14
  }
13
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
package/src/server/app.ts CHANGED
@@ -25,7 +25,7 @@ import type { Updates } from './updates.ts';
25
25
  import { Diagnostics } from './diagnostics.ts';
26
26
  import { attachmentIds } from './attachments.ts';
27
27
  import { MAX_ATTACHMENT_BYTES } from '../shared.ts';
28
- import { PaperImports } from './imports.ts';
28
+ import { PaperImports, openFolder } from './imports.ts';
29
29
  import { DATA } from './paths.ts';
30
30
  import { registerAiReviewRoutes } from './ai-review-routes.ts';
31
31
  import { detectLatex } from './latex-installation.ts';
@@ -174,6 +174,7 @@ export async function buildApp(options: AppOptions) {
174
174
  const string = (value: unknown, name: string, max = 20000) => { if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${name} is required and must be no longer than ${max} characters.`); return value.trim(); };
175
175
  // Adding a paper names a folder on this host, so only the owner can.
176
176
  app.post('/api/projects', owner, async request => { const b = request.body as any; return workspace.addProject(string(b.path, 'Repository path', 4000), b.name ? string(b.name, 'Paper name', 120) : undefined); });
177
+ app.post('/api/projects/open', owner, request => openFolder((request.body || {}) as any, root => store.projectByPath(root), (root, name) => workspace.addProject(root, name)));
177
178
  app.post('/api/imports', { ...owner, bodyLimit: 90 * 1024 * 1024 }, request => imports!.create((request.body || {}) as any));
178
179
  app.get<{ Params: { id: string } }>('/api/imports/:id', owner, request => imports!.get(request.params.id));
179
180
  app.post<{ Params: { id: string } }>('/api/imports/:id/build', owner, request => imports!.build(request.params.id, (request.body || {}) as any));
@@ -88,18 +88,25 @@ export async function inspectPaper(root: string) {
88
88
  const found = new Set(roots);
89
89
  candidates = [...roots, ...files.filter(file => !found.has(file))];
90
90
  }
91
- try {
92
- // Engine detection also works before Git initialization by reading the source hints.
93
- if (main) {
94
- const text = sourceHead(path.join(root, main));
95
- engine = typeof settings.engine === 'string' ? settings.engine : '';
96
- const rc = [path.join(path.dirname(main), '.latexmkrc'), path.join(path.dirname(main), 'latexmkrc'), '.latexmkrc', 'latexmkrc'].find(file => fs.existsSync(path.join(root, file)));
97
- const mode = rc ? fs.readFileSync(path.join(root, rc), 'utf8').match(/^\s*\$pdf_mode\s*=\s*(\d)/m)?.[1] : undefined;
98
- if (!engine) engine = text.match(/^%\s*!\s*TEX\s+(?:TS-)?program\s*=\s*(pdflatex|xelatex|lualatex)/im)?.[1].toLowerCase() || ({ '1': 'pdflatex', '4': 'lualatex', '5': 'xelatex' } as Record<string, string>)[mode || ''] || (/\\(?:usepackage(?:\[[^\]]*\])?\{[^}]*\b(?:fontspec|xeCJK|unicode-math|polyglossia)\b|documentclass(?:\[[^\]]*\])?\{ctex)/.test(text) ? 'xelatex' : 'pdflatex');
99
- }
100
- } catch (failure) { error = (failure as Error).message; }
91
+ try { if (main) engine = engineFor(root, main, settings); } catch (failure) { error = (failure as Error).message; }
101
92
  return { candidates, main, engine: engine || 'pdflatex', error };
102
93
  }
94
+ // Engine detection also works before Git initialization by reading the source hints.
95
+ function engineFor(root: string, main: string, settings: Record<string, unknown>) {
96
+ if (typeof settings.engine === 'string' && settings.engine) return settings.engine;
97
+ const text = sourceHead(path.join(root, main));
98
+ const rc = [path.join(path.dirname(main), '.latexmkrc'), path.join(path.dirname(main), 'latexmkrc'), '.latexmkrc', 'latexmkrc'].find(file => fs.existsSync(path.join(root, file)));
99
+ const mode = rc ? fs.readFileSync(path.join(root, rc), 'utf8').match(/^\s*\$pdf_mode\s*=\s*(\d)/m)?.[1] : undefined;
100
+ return text.match(/^%\s*!\s*TEX\s+(?:TS-)?program\s*=\s*(pdflatex|xelatex|lualatex)/im)?.[1].toLowerCase() || ({ '1': 'pdflatex', '4': 'lualatex', '5': 'xelatex' } as Record<string, string>)[mode || ''] || (/\\(?:usepackage(?:\[[^\]]*\])?\{[^}]*\b(?:fontspec|xeCJK|unicode-math|polyglossia)\b|documentclass(?:\[[^\]]*\])?\{ctex)/.test(text) ? 'xelatex' : 'pdflatex');
101
+ }
102
+ // Keep the author's settings, including a source folder that still contains
103
+ // the main file; without one, the whole folder compiles.
104
+ function rememberMain(root: string, main: string, engine: string) {
105
+ const settings = config(root), folder = typeof settings.source_dir === 'string' ? settings.source_dir.replace(/\/+$/, '') : null;
106
+ const contains = folder === '' || (folder !== null && main.startsWith(folder + '/'));
107
+ const next = { ...settings, main, engine, ...(contains ? {} : { source_dir: '' }) };
108
+ if (JSON.stringify(next) !== JSON.stringify(settings)) fs.writeFileSync(path.join(root, CONFIG), JSON.stringify(next, null, 2) + '\n');
109
+ }
103
110
  async function repositoryRoot(root: string) {
104
111
  let top: string | undefined;
105
112
  try { top = await git(root, 'rev-parse', '--show-toplevel'); } catch { /* A plain folder needs Git. */ }
@@ -115,6 +122,28 @@ export async function prepareRepository(root: string) {
115
122
  await git(root, 'add', '--all');
116
123
  await git(root, '-c', 'user.name=Papilot', '-c', 'user.email=papilot@localhost', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Import paper into Papilot');
117
124
  }
125
+ // The desktop app opens a folder the way an editor does. A paper already in
126
+ // Papilot opens again; any other folder with a clear main file is added as it
127
+ // is and compiles in its own view. Only a folder with several possible main
128
+ // files returns them, so the author can pick one.
129
+ export async function openFolder(input: { path?: unknown; main?: unknown }, known: (root: string) => Project | undefined, register: (root: string, name: string) => Promise<Project>): Promise<{ project: Project } | { name: string; candidates: string[] }> {
130
+ if (typeof input.path !== 'string' || !input.path.trim()) throw new Error('Enter a folder path.');
131
+ const root = fs.realpathSync(input.path);
132
+ if (!fs.statSync(root).isDirectory()) throw new Error('Select a folder containing the paper.');
133
+ const project = known(root);
134
+ if (project) return { project };
135
+ await repositoryRoot(root);
136
+ const detected = await inspectPaper(root);
137
+ if (detected.error) throw new Error(detected.error);
138
+ if (input.main === undefined && !detected.main) {
139
+ if (!detected.candidates.length) throw new Error('Select a folder containing the paper.');
140
+ return { name: path.basename(root), candidates: detected.candidates };
141
+ }
142
+ const main = input.main ?? detected.main;
143
+ if (!existingTex(root, main)) throw new Error('Choose an existing TeX main file.');
144
+ rememberMain(root, main, engineFor(root, main, config(root)));
145
+ return { project: await register(root, path.basename(root)) };
146
+ }
118
147
  export async function extractZip(buffer: Buffer, root: string) {
119
148
  if (buffer.length > MAX_BYTES) throw new Error('Choose a ZIP smaller than 64 MB.');
120
149
  const zip = await new Promise<yauzl.ZipFile>((resolve, reject) => yauzl.fromBuffer(buffer, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, value) => error ? reject(error) : resolve(value)));
@@ -214,14 +243,7 @@ export class PaperImports {
214
243
  void (async () => {
215
244
  try {
216
245
  await repositoryRoot(value.path);
217
- if (!repair) {
218
- // Keep the author's settings, including a source folder that still
219
- // contains the main file; without one, the whole folder compiles.
220
- const settings = config(value.path), folder = typeof settings.source_dir === 'string' ? settings.source_dir.replace(/\/+$/, '') : null;
221
- const contains = folder === '' || (folder !== null && (main as string).startsWith(folder + '/'));
222
- const next = { ...settings, main, engine, ...(contains ? {} : { source_dir: '' }) };
223
- if (JSON.stringify(next) !== JSON.stringify(settings)) fs.writeFileSync(path.join(value.path, CONFIG), JSON.stringify(next, null, 2) + '\n');
224
- }
246
+ if (!repair) rememberMain(value.path, main as string, engine as string);
225
247
  await prepareRepository(value.path); value.versioned = true;
226
248
  if (repair) { value.summary = await repair(value.path, previousError); Object.assign(value, await inspectPaper(value.path)); }
227
249
  value.phase = 'building'; this.save(value);