exegete 0.14.1a0.dev1__py3-none-any.whl

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.
exegete/new_project.py ADDED
@@ -0,0 +1,1027 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ """An empty QualCoder project in QualCoder 4.0's format (v0.14).
3
+
4
+ What `create_project` writes: the project folder with its four
5
+ subfolders, and a `data.qda` holding the tables, views and first rows
6
+ that QualCoder 4.0's own New Project writes (schema v17), built from the
7
+ column table below in ONE explicit transaction, so that a crash leaves
8
+ no committed table.
9
+
10
+ The column table is this project's own restatement of the file format
11
+ (NOTICE lists it among the facts of QualCoder's file format). It is not
12
+ QualCoder's statement text: QualCoder never reads the stored text of a
13
+ table (it looks tables up by name and reads `PRAGMA table_info`), so
14
+ what must match is the order of the tables, each column's name, type,
15
+ key, NOT NULL and default, the unique groups, the views' behaviour and
16
+ the first rows. `tests/test_v014_create_project_format.py` compares all
17
+ of them with a project QualCoder 4.0 created, from a committed fixture.
18
+
19
+ The format followed is the newest downloadable QualCoder 4.0 (the
20
+ owner's ruling of 2026-09-25): the "4.0-Beta" pre-release of 2026-09-03
21
+ writes exactly what the verified commit writes, so both are named by
22
+ one constant, `database.VERIFIED_MASTER_COMMIT`. It is re-checked at
23
+ each of this server's releases while 4.0 is in beta.
24
+ """
25
+
26
+ import datetime
27
+ import errno
28
+ import os
29
+ import sqlite3
30
+ import stat
31
+ import unicodedata
32
+ from pathlib import Path
33
+ from typing import Dict, List, Optional, Sequence, Tuple, Union
34
+
35
+ from . import names
36
+ from .database import (
37
+ MAX_FILE_NAME_BYTES,
38
+ VERIFIED_MASTER_COMMIT,
39
+ _sqlite_ro_uri,
40
+ documents_name_key,
41
+ file_name_rule,
42
+ )
43
+
44
+ # The schema label a new project carries, and the build that writes it.
45
+ SCHEMA_VERSION = "v17"
46
+ FORMAT_COMMIT = VERIFIED_MASTER_COMMIT
47
+
48
+ # The project folder's contents, in the order QualCoder makes them.
49
+ DATABASE_FILE = "data.qda"
50
+ SUBFOLDERS = ("images", "audio", "video", "documents")
51
+
52
+ # QualCoder's speaker coder, which every new project lists among its
53
+ # coder names (a system coder, never a person).
54
+ SPEAKER_CODER_NAME = "\U0001F4CC Speaker coding"
55
+
56
+ # The date QualCoder stores: local time, offset applied, to the second.
57
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
58
+
59
+ _INT, _TEXT, _REAL = "integer", "text", "real"
60
+ _KEY = True
61
+
62
+ # (table, ((column, type[, primary key]), ...), (unique group, ...)), in
63
+ # the order QualCoder 4.0 creates them. `project`, `stored_sql` and `ris`
64
+ # have no primary key; `attribute_type`'s key is its text name; every
65
+ # other table's first column is an integer key. `linewidth` is the only
66
+ # real column. No table has a foreign key.
67
+ TABLES: Tuple[Tuple[str, Tuple[tuple, ...], Tuple[Tuple[str, ...], ...]],
68
+ ...] = (
69
+ ("project", (
70
+ ("databaseversion", _TEXT), ("date", _TEXT), ("memo", _TEXT),
71
+ ("about", _TEXT), ("bookmarkfile", _INT), ("bookmarkpos", _INT),
72
+ ("codername", _TEXT), ("recently_used_codes", _TEXT),
73
+ ("avbookmarkfile", _INT), ("avbookmarkmsec", _INT),
74
+ ("avbookmarktextpos", _INT)), ()),
75
+ ("source", (
76
+ ("id", _INT, _KEY), ("name", _TEXT), ("fulltext", _TEXT),
77
+ ("mediapath", _TEXT), ("memo", _TEXT), ("owner", _TEXT),
78
+ ("date", _TEXT), ("av_text_id", _INT), ("risid", _INT)),
79
+ (("name",),)),
80
+ ("code_image", (
81
+ ("imid", _INT, _KEY), ("id", _INT), ("x1", _INT), ("y1", _INT),
82
+ ("width", _INT), ("height", _INT), ("cid", _INT), ("memo", _TEXT),
83
+ ("date", _TEXT), ("owner", _TEXT), ("important", _INT),
84
+ ("pdf_page", _INT)), ()),
85
+ ("code_av", (
86
+ ("avid", _INT, _KEY), ("id", _INT), ("pos0", _INT), ("pos1", _INT),
87
+ ("cid", _INT), ("memo", _TEXT), ("date", _TEXT), ("owner", _TEXT),
88
+ ("important", _INT)), ()),
89
+ ("annotation", (
90
+ ("anid", _INT, _KEY), ("fid", _INT), ("pos0", _INT), ("pos1", _INT),
91
+ ("memo", _TEXT), ("owner", _TEXT), ("date", _TEXT)),
92
+ (("fid", "pos0", "pos1", "owner"),)),
93
+ ("attribute_type", (
94
+ ("name", _TEXT, _KEY), ("date", _TEXT), ("owner", _TEXT),
95
+ ("memo", _TEXT), ("caseOrFile", _TEXT), ("valuetype", _TEXT)), ()),
96
+ ("attribute", (
97
+ ("attrid", _INT, _KEY), ("name", _TEXT), ("attr_type", _TEXT),
98
+ ("value", _TEXT), ("id", _INT), ("date", _TEXT), ("owner", _TEXT)),
99
+ (("name", "attr_type", "id"),)),
100
+ ("case_text", (
101
+ ("id", _INT, _KEY), ("caseid", _INT), ("fid", _INT), ("pos0", _INT),
102
+ ("pos1", _INT), ("owner", _TEXT), ("date", _TEXT), ("memo", _TEXT)),
103
+ ()),
104
+ ("cases", (
105
+ ("caseid", _INT, _KEY), ("name", _TEXT), ("memo", _TEXT),
106
+ ("owner", _TEXT), ("date", _TEXT)), (("name",),)),
107
+ ("code_cat", (
108
+ ("catid", _INT, _KEY), ("name", _TEXT), ("owner", _TEXT),
109
+ ("date", _TEXT), ("memo", _TEXT), ("supercatid", _INT)),
110
+ (("name",),)),
111
+ ("code_text", (
112
+ ("ctid", _INT, _KEY), ("cid", _INT), ("fid", _INT),
113
+ ("seltext", _TEXT), ("pos0", _INT), ("pos1", _INT), ("owner", _TEXT),
114
+ ("date", _TEXT), ("memo", _TEXT), ("avid", _INT),
115
+ ("important", _INT)),
116
+ (("cid", "fid", "pos0", "pos1", "owner"),)),
117
+ # supercid: the parent code of a sub-code (4.0's sub-codes)
118
+ ("code_name", (
119
+ ("cid", _INT, _KEY), ("name", _TEXT), ("memo", _TEXT),
120
+ ("catid", _INT), ("owner", _TEXT), ("date", _TEXT), ("color", _TEXT),
121
+ ("supercid", _INT)), (("name",),)),
122
+ ("journal", (
123
+ ("jid", _INT, _KEY), ("name", _TEXT), ("jentry", _TEXT),
124
+ ("date", _TEXT), ("owner", _TEXT)), (("name",),)),
125
+ ("stored_sql", (
126
+ ("title", _TEXT), ("description", _TEXT), ("grouper", _TEXT),
127
+ ("ssql", _TEXT)), (("title",),)),
128
+ ("graph", (
129
+ ("grid", _INT, _KEY), ("name", _TEXT), ("description", _TEXT),
130
+ ("date", _TEXT), ("scene_width", _INT), ("scene_height", _INT)),
131
+ (("name",),)),
132
+ ("gr_cdct_text_item", (
133
+ ("gtextid", _INT, _KEY), ("grid", _INT), ("x", _INT), ("y", _INT),
134
+ ("supercatid", _INT), ("catid", _INT), ("cid", _INT),
135
+ ("font_size", _INT), ("bold", _INT), ("isvisible", _INT),
136
+ ("displaytext", _TEXT)), ()),
137
+ ("gr_case_text_item", (
138
+ ("gcaseid", _INT, _KEY), ("grid", _INT), ("x", _INT), ("y", _INT),
139
+ ("caseid", _INT), ("font_size", _INT), ("bold", _INT),
140
+ ("color", _TEXT), ("displaytext", _TEXT)), ()),
141
+ ("gr_file_text_item", (
142
+ ("gfileid", _INT, _KEY), ("grid", _INT), ("x", _INT), ("y", _INT),
143
+ ("fid", _INT), ("font_size", _INT), ("bold", _INT), ("color", _TEXT),
144
+ ("displaytext", _TEXT)), ()),
145
+ ("gr_free_text_item", (
146
+ ("gfreeid", _INT, _KEY), ("grid", _INT), ("freetextid", _INT),
147
+ ("x", _INT), ("y", _INT), ("free_text", _TEXT), ("font_size", _INT),
148
+ ("bold", _INT), ("color", _TEXT), ("tooltip", _TEXT), ("ctid", _INT),
149
+ ("memo_ctid", _INT), ("memo_imid", _INT), ("memo_avid", _INT)), ()),
150
+ # label and arrow_mode: 4.0's relation labels and arrows
151
+ ("gr_cdct_line_item", (
152
+ ("glineid", _INT, _KEY), ("grid", _INT), ("fromcatid", _INT),
153
+ ("fromcid", _INT), ("tocatid", _INT), ("tocid", _INT),
154
+ ("color", _TEXT), ("linewidth", _REAL), ("linetype", _TEXT),
155
+ ("isvisible", _INT), ("label", _TEXT), ("arrow_mode", _TEXT)), ()),
156
+ ("gr_free_line_item", (
157
+ ("gflineid", _INT, _KEY), ("grid", _INT))
158
+ + tuple((f"{end}{item}", _INT) for end in ("from", "to")
159
+ for item in ("freetextid", "catid", "cid", "caseid", "fileid",
160
+ "imid", "avid"))
161
+ + (("color", _TEXT), ("linewidth", _REAL), ("linetype", _TEXT),
162
+ ("label", _TEXT), ("arrow_mode", _TEXT)), ()),
163
+ # 4.0's memo nodes on graphs
164
+ ("gr_memo_item", (
165
+ ("gmemoid", _INT, _KEY), ("grid", _INT), ("memo_source_type", _TEXT),
166
+ ("memo_source_id", _INT), ("x", _INT), ("y", _INT), ("color", _TEXT),
167
+ ("font_size", _INT)), ()),
168
+ ("gr_pix_item", (
169
+ ("grpixid", _INT, _KEY), ("grid", _INT), ("imid", _INT), ("x", _INT),
170
+ ("y", _INT), ("px", _INT), ("py", _INT), ("w", _INT), ("h", _INT),
171
+ ("filepath", _TEXT), ("tooltip", _TEXT), ("pdf_page", _INT)), ()),
172
+ ("gr_av_item", (
173
+ ("gr_avid", _INT, _KEY), ("grid", _INT), ("avid", _INT), ("x", _INT),
174
+ ("y", _INT), ("pos0", _INT), ("pos1", _INT), ("filepath", _TEXT),
175
+ ("tooltip", _TEXT), ("color", _TEXT)), ()),
176
+ ("ris", (
177
+ ("risid", _INT), ("tag", _TEXT), ("longtag", _TEXT),
178
+ ("value", _TEXT)), ()),
179
+ ("manage_files_display", (
180
+ ("mfid", _INT, _KEY), ("name", _TEXT), ("tblrows", _TEXT),
181
+ ("tblcolumns", _TEXT), ("owner", _TEXT)), ()),
182
+ ("files_filter", (
183
+ ("filterid", _INT, _KEY), ("name", _TEXT), ("filter", _TEXT),
184
+ ("owner", _TEXT)), ()),
185
+ )
186
+
187
+ # The coder names table: the one table with NOT NULL, a default and a
188
+ # CHECK. QualCoder 4.0 creates it after the others, fills it, then
189
+ # creates the four views that hide a coder whose visibility is 0.
190
+ CODER_NAMES_TABLE = (
191
+ "CREATE TABLE coder_names (name TEXT UNIQUE NOT NULL, "
192
+ "visibility INTEGER NOT NULL DEFAULT 1 CHECK (visibility IN (0, 1)))")
193
+ VISIBILITY_VIEW_TABLES = ("code_image", "code_text", "code_av", "annotation")
194
+
195
+ # The project row's columns, named (QualCoder inserts by position; the
196
+ # stored row is the same).
197
+ PROJECT_COLUMNS = (
198
+ "databaseversion", "date", "memo", "about", "bookmarkfile",
199
+ "bookmarkpos", "codername", "recently_used_codes", "avbookmarkfile",
200
+ "avbookmarkmsec", "avbookmarktextpos")
201
+
202
+ Statement = Tuple[str, Tuple]
203
+
204
+
205
+ def about_line(version: str) -> str:
206
+ """The project row's `about`: this server and its version, and the
207
+ word QualCoder's open check looks for ("QualCoder", with that
208
+ capitalisation: both builds refuse a project whose `about` lacks
209
+ it). QualCoder shows it and tests it, and reads nothing else in it."""
210
+ return (f"{names.SERVER_NAME} {version} "
211
+ f"(QualCoder schema {SCHEMA_VERSION})")
212
+
213
+
214
+ def creation_date(now: Optional[datetime.datetime] = None) -> str:
215
+ """The date QualCoder writes into a new project row."""
216
+ now = now or datetime.datetime.now()
217
+ return now.astimezone().strftime(DATE_FORMAT)
218
+
219
+
220
+ def _column_text(column: tuple) -> str:
221
+ name, kind = column[0], column[1]
222
+ return f"{name} {kind}" + (" primary key" if len(column) > 2 else "")
223
+
224
+
225
+ def table_statement(table: str, columns: Sequence[tuple],
226
+ uniques: Sequence[Sequence[str]]) -> str:
227
+ """One CREATE TABLE statement from the column table."""
228
+ parts = [_column_text(column) for column in columns]
229
+ parts += [f"unique({', '.join(group)})" for group in uniques]
230
+ return f"CREATE TABLE {table} ({', '.join(parts)})"
231
+
232
+
233
+ def view_statement(table: str) -> str:
234
+ """A coder-visibility view: the table's rows, less a hidden coder's."""
235
+ return (f"CREATE VIEW {table}_visible AS SELECT t.* FROM {table} t "
236
+ f"WHERE NOT EXISTS (SELECT 1 FROM coder_names c "
237
+ f"WHERE c.name = t.owner AND c.visibility = 0)")
238
+
239
+
240
+ def creation_statements(coder_name: str, about: str,
241
+ date: str) -> List[Statement]:
242
+ """Every statement of a new project's database, in QualCoder 4.0's
243
+ order: the 27 tables, `coder_names`, its rows (the researcher's coder
244
+ name when known, then the speaker coder), the four views and the
245
+ project row. `coder_name` is '' when the researcher's name is not
246
+ known: the row is then left out and the project row stores ''."""
247
+ if not isinstance(coder_name, str):
248
+ raise TypeError("coder_name must be a string ('' when not known)")
249
+ statements: List[Statement] = [
250
+ (table_statement(table, columns, uniques), ())
251
+ for table, columns, uniques in TABLES]
252
+ statements.append((CODER_NAMES_TABLE, ()))
253
+ if coder_name:
254
+ statements.append((
255
+ "INSERT INTO coder_names (name, visibility) VALUES (?, 1)",
256
+ (coder_name,)))
257
+ statements.append((
258
+ "INSERT OR IGNORE INTO coder_names (name) VALUES (?)",
259
+ (SPEAKER_CODER_NAME,)))
260
+ statements.extend((view_statement(table), ())
261
+ for table in VISIBILITY_VIEW_TABLES)
262
+ statements.append((
263
+ f"INSERT INTO project ({', '.join(PROJECT_COLUMNS)}) "
264
+ f"VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)",
265
+ (SCHEMA_VERSION, date, "", about, 0, 0, coder_name, "")))
266
+ return statements
267
+
268
+
269
+ def _connect(path: Path) -> sqlite3.Connection:
270
+ """A connection with no implicit transactions: the caller's BEGIN
271
+ and COMMIT are the only ones (Python's default mode would commit
272
+ each CREATE TABLE on its own)."""
273
+ return sqlite3.connect(str(path), isolation_level=None)
274
+
275
+
276
+ def build_database(path: Union[str, Path],
277
+ statements: Sequence[Statement]) -> None:
278
+ """Write `statements` into a new database at `path` in one explicit
279
+ transaction. A failure rolls back and re-raises; the connection is
280
+ closed either way, so the caller can remove the file afterwards (on
281
+ Windows an open file cannot be removed). Nothing reaches the file
282
+ before COMMIT: a new database's pages stay in memory until then, so a
283
+ process killed part way leaves an empty file and a journal."""
284
+ conn = _connect(Path(path))
285
+ try:
286
+ conn.execute("BEGIN")
287
+ for sql, args in statements:
288
+ conn.execute(sql, args)
289
+ conn.execute("COMMIT")
290
+ except BaseException:
291
+ try:
292
+ if conn.in_transaction:
293
+ conn.execute("ROLLBACK")
294
+ except sqlite3.Error:
295
+ pass
296
+ raise
297
+ finally:
298
+ conn.close()
299
+
300
+
301
+ # The files SQLite may make beside the database while it is written. The
302
+ # journal is this call's only when the database is; `-wal` and `-shm` are
303
+ # never made by it (the database keeps a rollback journal) and never
304
+ # removed.
305
+ _DATABASE_SIDE_FILES = ("data.qda-journal", "data.qda-wal", "data.qda-shm")
306
+ _JOURNAL = "data.qda-journal"
307
+ _NOT_EMPTY = {getattr(errno, "ENOTEMPTY", -1), errno.EEXIST}
308
+
309
+
310
+ class Leftovers:
311
+ """What a failed creation's clean-up could not remove.
312
+
313
+ `kept`: entries that are not what this call made, or that hold
314
+ something it did not make, so they stay (the folder itself as ".").
315
+ `failed`: entries this call made whose removal the system refused.
316
+ `replaced`: the folder is no longer the one the call claimed (swapped
317
+ for another folder or a link), so nothing in it was touched.
318
+ """
319
+
320
+ def __init__(self) -> None:
321
+ self.kept: List[str] = []
322
+ self.failed: List[str] = []
323
+ self.replaced = False
324
+
325
+ @property
326
+ def nothing_left(self) -> bool:
327
+ return not (self.kept or self.failed or self.replaced)
328
+
329
+
330
+ class ProjectWriteFailed(Exception):
331
+ """Writing a new project failed after its folder was claimed.
332
+
333
+ `stage` is "subfolder" or "database"; `cause` is the error that
334
+ stopped it (chained, as `__cause__`); `leftovers` says what the
335
+ clean-up left and why. The caller words the answer; the cause's own
336
+ message is never shown, because an operating system or SQLite
337
+ message can carry a path.
338
+ """
339
+
340
+ def __init__(self, stage: str, leftovers: Leftovers):
341
+ super().__init__(f"project write failed at the {stage} stage")
342
+ self.stage = stage
343
+ self.leftovers = leftovers
344
+
345
+ @property
346
+ def cause(self) -> Optional[BaseException]:
347
+ return self.__cause__
348
+
349
+
350
+ Identity = Tuple[int, int]
351
+
352
+
353
+ def _identity(path: Path) -> Optional[Identity]:
354
+ """The (device, inode) of `path` itself, a link never followed."""
355
+ info = _lstat(path)
356
+ return None if info is None else (info.st_dev, info.st_ino)
357
+
358
+
359
+ def _create_database_file(path: Path) -> None:
360
+ """Make the empty `data.qda` this call will fill: exclusively, so a
361
+ file, link or folder of that name (another program's) fails the call
362
+ and is never opened, written or removed. An empty file is a valid new
363
+ SQLite database."""
364
+ flags = (os.O_WRONLY | os.O_CREAT | os.O_EXCL
365
+ | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0))
366
+ os.close(os.open(path, flags, 0o666))
367
+
368
+
369
+ def _remove(path: Path, is_folder: bool, name: str,
370
+ leftovers: Leftovers) -> None:
371
+ try:
372
+ path.rmdir() if is_folder else path.unlink()
373
+ except FileNotFoundError:
374
+ pass
375
+ except OSError as error:
376
+ if is_folder and error.errno in _NOT_EMPTY:
377
+ leftovers.kept.append(name)
378
+ else:
379
+ leftovers.failed.append(name)
380
+
381
+
382
+ def remove_what_was_made(folder: Path,
383
+ made: Dict[str, Optional[Identity]]) -> Leftovers:
384
+ """Remove what one creation made, and nothing else, never recursively.
385
+
386
+ `made` holds the device and inode of each thing the call made (the
387
+ folder as "."). Before each removal the folder and the entry are read
388
+ again with `lstat`: an entry that is not the one made (another
389
+ program's, or put in its place) is kept, and a folder that is not the
390
+ one claimed is not touched at all. The journal goes only with this
391
+ call's own database; `-wal` and `-shm`, which it never makes, stay.
392
+ A folder that still holds anything is kept.
393
+ """
394
+ leftovers = Leftovers()
395
+ claimed = made.get(".")
396
+
397
+ def still_ours() -> bool:
398
+ info = _lstat(folder)
399
+ return (info is not None and stat.S_ISDIR(info.st_mode)
400
+ and (info.st_dev, info.st_ino) == claimed)
401
+
402
+ if claimed is None or not still_ours():
403
+ leftovers.replaced = True
404
+ return leftovers
405
+ database = folder / DATABASE_FILE
406
+ if made.get(DATABASE_FILE) is not None \
407
+ and _identity(database) == made[DATABASE_FILE]:
408
+ journal = _lstat(folder / _JOURNAL)
409
+ if journal is not None and stat.S_ISREG(journal.st_mode):
410
+ _remove(folder / _JOURNAL, False, _JOURNAL, leftovers)
411
+ _remove(database, False, DATABASE_FILE, leftovers)
412
+ for name in reversed(SUBFOLDERS):
413
+ entry = folder / name
414
+ if name in made and _identity(entry) == made[name]:
415
+ _remove(entry, True, name, leftovers)
416
+ if not still_ours():
417
+ leftovers.replaced = True
418
+ return leftovers
419
+ _remove(folder, True, ".", leftovers)
420
+ return leftovers
421
+
422
+
423
+ def write_project(folder: Union[str, Path],
424
+ statements: Sequence[Statement]) -> Path:
425
+ """Make a new project at `folder`: claim it, make the four
426
+ subfolders, and build its database in one transaction.
427
+
428
+ The claim is an atomic `mkdir`: a folder, file or link of that name
429
+ raises FileExistsError and nothing is touched. The database file is
430
+ then made exclusively. Each thing made is recorded by device and
431
+ inode; after the claim, any failure removes those and nothing else
432
+ (`remove_what_was_made`) and raises ProjectWriteFailed. Returns the
433
+ path of `data.qda`.
434
+ """
435
+ folder = Path(folder)
436
+ folder.mkdir()
437
+ made: Dict[str, Optional[Identity]] = {".": _identity(folder)}
438
+ stage = "subfolder"
439
+ try:
440
+ for name in SUBFOLDERS:
441
+ (folder / name).mkdir()
442
+ made[name] = _identity(folder / name)
443
+ stage = "database"
444
+ _create_database_file(folder / DATABASE_FILE)
445
+ made[DATABASE_FILE] = _identity(folder / DATABASE_FILE)
446
+ build_database(folder / DATABASE_FILE, statements)
447
+ except BaseException as error:
448
+ leftovers = remove_what_was_made(folder, made)
449
+ if not isinstance(error, Exception):
450
+ raise
451
+ # chained, never carried in the message (v0.14's privacy rule)
452
+ raise ProjectWriteFailed(stage, leftovers) from error
453
+ return folder / DATABASE_FILE
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # The project's name (v0.13's file-name rule, worded for a project, and
458
+ # the project-only refusals). The folder is "<name>.qda".
459
+ # ---------------------------------------------------------------------------
460
+
461
+ PROJECT_SUFFIX = ".qda"
462
+ # This server's backups are "<project>_backup_<time>[...].qda" and
463
+ # QualCoder's "<project>_BKUP_<date>[<operation>].qda": a name holding
464
+ # either is hidden by this server's project list, and a folder named
465
+ # like another project's "_BKUP_" backup is deleted by QualCoder 4.0
466
+ # beyond the newest five when that project closes.
467
+ BACKUP_MARKERS = ("_backup_", "_BKUP_")
468
+
469
+ _PROJECT_NAME_TEXT = {
470
+ "empty": "A project name must not be empty, spaces only or dots only.",
471
+ "surrogate": ("A project name must not contain an unpaired surrogate "
472
+ "character."),
473
+ "path": ("A project name must not contain '/', '\\', '..' or ':': it "
474
+ "names one folder. To create the project somewhere else, "
475
+ "name that folder in `directory`."),
476
+ "windows_characters": (
477
+ "A project name must not contain < > ? * or \" (Windows cannot "
478
+ "store them in a folder name, and a project may be moved to "
479
+ "Windows)."),
480
+ "windows_ending": (
481
+ "A project name must not end with a dot or a space (Windows drops "
482
+ "them, so there the folder would have another name)."),
483
+ "windows_device": (
484
+ "A project name must not be a Windows device name (CON, PRN, AUX, "
485
+ "NUL, CONIN$, CONOUT$, COM0 to COM9, LPT0 to LPT9, or COM or LPT "
486
+ "followed by a superscript 1, 2 or 3): Windows cannot store such "
487
+ "a folder."),
488
+ }
489
+
490
+ PIPE_REFUSAL = (
491
+ "A project name must not contain '|': QualCoder creates such a project "
492
+ "but can never open it, because it reads the text after a '|' as the "
493
+ "project's path.")
494
+
495
+
496
+ def normalise_project_name(raw: str) -> str:
497
+ """The name as it will be used: stripped at both ends, NFC, and one
498
+ trailing '.qda' (any letter case) removed, since a researcher who
499
+ types "Study.qda" means the folder "Study.qda" (QualCoder would make
500
+ "Study.qda.qda")."""
501
+ name = unicodedata.normalize("NFC", raw.strip())
502
+ if name.lower().endswith(PROJECT_SUFFIX):
503
+ name = name[:-len(PROJECT_SUFFIX)]
504
+ return name
505
+
506
+
507
+ def project_name_problem(name: str) -> Optional[str]:
508
+ """Why `name` (already normalised) may not name a project, or None.
509
+
510
+ v0.13's file-name rule first (`file_name_rule`: the same rules as a
511
+ file name, in the same order, worded for a project folder), then the
512
+ project-only refusals: a backup marker, a leading dot, and a name
513
+ still ending in '.qda'.
514
+ """
515
+ broken = file_name_rule(name)
516
+ if broken is not None:
517
+ rule, detail = broken
518
+ if rule == "invisible":
519
+ return f"A project name must not contain {detail}."
520
+ if rule == "too_long":
521
+ return (f"A project name must be at most {MAX_FILE_NAME_BYTES} "
522
+ f"bytes in UTF-8 (this one has {detail}): the limit "
523
+ f"leaves room for the backup names QualCoder and this "
524
+ f"server add beside a project.")
525
+ if rule == "windows_characters" and "|" in name:
526
+ return PIPE_REFUSAL
527
+ return _PROJECT_NAME_TEXT[rule]
528
+ for marker in BACKUP_MARKERS:
529
+ if marker in name:
530
+ return (f"A project name must not contain '{marker}': this "
531
+ f"server's project list hides such folders as backups, "
532
+ f"and QualCoder 4.0 deletes a folder named like "
533
+ f"another project's '_BKUP_' backup, beyond the newest "
534
+ f"five, when it closes that project.")
535
+ if name.startswith("."):
536
+ return ("A project name must not start with a dot: the folder "
537
+ "would be hidden.")
538
+ if name.lower().endswith(PROJECT_SUFFIX):
539
+ return ("A project name must not end in '.qda' twice; give the name "
540
+ "without the ending.")
541
+ return None
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Where the project goes: the folder guard (new code, not the export path
546
+ # resolver, which is made for files and needs a project selected).
547
+ # ---------------------------------------------------------------------------
548
+
549
+ class Refusal(ValueError):
550
+ """A creation refused before anything was written; the message is the
551
+ tool's answer."""
552
+
553
+
554
+ def _inside(path: Path, folder: Optional[Path]) -> bool:
555
+ """Whether `path` is `folder` or inside it: by spelling, and by
556
+ identity (device and inode), so another letter case of the same
557
+ folder on a disk that ignores case, or another way to it, is the same
558
+ folder (fix round 1 of brief C, Security 3)."""
559
+ if folder is None:
560
+ return False
561
+ if path == folder or folder in path.parents:
562
+ return True
563
+ try:
564
+ target = os.stat(folder)
565
+ except OSError:
566
+ return False
567
+ if not target.st_ino:
568
+ return False # a file system with no inode numbers
569
+ for part in (path,) + tuple(path.parents):
570
+ try:
571
+ info = os.stat(part)
572
+ except OSError:
573
+ continue
574
+ if (info.st_dev, info.st_ino) == (target.st_dev, target.st_ino):
575
+ return True
576
+ return False
577
+
578
+
579
+ def check_parent_folder(parent: Path, state_home: Optional[Path],
580
+ is_default: bool,
581
+ qualcoder_settings: Optional[Path] = None,
582
+ old_state_home: Optional[Path] = None) -> None:
583
+ """Refuse a parent folder a project must not be created in.
584
+
585
+ `parent` is resolved. The researcher's own folder must exist and be
586
+ a folder (this tool creates no folder but its workspace); no parent
587
+ may lie inside this server's state folder, inside a folder whose
588
+ name ends in '.qda' (a project inside a project is copied into every
589
+ backup of the outer one), or on a path containing '|' (QualCoder
590
+ cannot open such a project).
591
+ """
592
+ where = "The workspace folder" if is_default else "The folder"
593
+ if not is_default:
594
+ if not parent.exists():
595
+ raise Refusal(
596
+ f"{where} '{parent}' does not exist. Name an existing "
597
+ f"folder (this tool creates no folder but its own "
598
+ f"workspace), or leave `directory` out to use the "
599
+ f"workspace.")
600
+ if not parent.is_dir():
601
+ raise Refusal(f"'{parent}' is not a folder. Name an existing "
602
+ f"folder, or leave `directory` out.")
603
+ # v0.14.1: the folder the state folder was moved from is refused as
604
+ # well, for good, whether or not it exists
605
+ if _inside(parent, state_home) or _inside(parent, old_state_home):
606
+ raise Refusal(
607
+ f"{where} is inside this server's state folder "
608
+ f"(~/.exegete, or ~/.qualcoder_mcp, its earlier name), which "
609
+ f"holds its internal state; choose another folder.")
610
+ if _inside(parent, qualcoder_settings):
611
+ raise Refusal(
612
+ f"{where} is inside QualCoder's own settings folder "
613
+ f"(~/.qualcoder), which holds its settings and API keys; a "
614
+ f"project and its backups beside them invite a later copy or "
615
+ f"sync of that folder. Choose another folder.")
616
+ for part in (parent,) + tuple(parent.parents):
617
+ if part.name.lower().endswith(PROJECT_SUFFIX):
618
+ raise Refusal(
619
+ f"{where} '{parent}' is inside the project folder "
620
+ f"'{part.name}'. A project inside a project is copied into "
621
+ f"every backup of the outer one; choose a folder outside "
622
+ f"it.")
623
+ if "|" in str(parent):
624
+ raise Refusal(
625
+ f"{where} '{parent}' has a '|' in its path. QualCoder creates a "
626
+ f"project there but can never open it, because it reads the "
627
+ f"text after a '|' as the path; choose another folder.")
628
+
629
+
630
+ def resolve_parent_folder(directory: Optional[str],
631
+ workspace: Path) -> Tuple[Path, bool]:
632
+ """(the parent folder, resolved; whether it is the workspace)."""
633
+ if directory is None:
634
+ return Path(workspace).expanduser().resolve(), True
635
+ if not isinstance(directory, str):
636
+ raise Refusal("`directory` must be a folder path given as text, or "
637
+ "left out to use the workspace.")
638
+ if not directory.strip():
639
+ raise Refusal("`directory` is empty. Name an existing folder, or "
640
+ "leave `directory` out to use the workspace.")
641
+ try:
642
+ given = Path(directory).expanduser()
643
+ except (RuntimeError, ValueError):
644
+ raise Refusal(f"'{directory}' is not a folder path this tool can "
645
+ f"use.") from None
646
+ if not given.is_absolute():
647
+ # Read from the server's working folder, which the host chooses
648
+ # and the researcher cannot see (Security 5); on Windows "C:x" is
649
+ # relative to that drive's working folder, and "\x" to the drive.
650
+ raise Refusal(
651
+ f"'{directory}' is a relative path, which would be read from "
652
+ f"the server's own working folder. Give the folder's full path, "
653
+ f"or one starting with ~ (the home folder).")
654
+ try:
655
+ return given.resolve(), False
656
+ except (OSError, RuntimeError, ValueError):
657
+ raise Refusal(f"'{directory}' is not a folder path this tool can "
658
+ f"use.") from None
659
+
660
+
661
+ # ---------------------------------------------------------------------------
662
+ # What is already there: the same name (by the strictest disk's rule), and
663
+ # older folders named like the new project's backups.
664
+ # ---------------------------------------------------------------------------
665
+
666
+ ORPHAN = "orphan"
667
+ NO_DATABASE = "no_database"
668
+ PROJECT = "project"
669
+ UNREADABLE = "unreadable"
670
+ NOT_A_FOLDER = "not_a_folder"
671
+
672
+ # What a creation writes into its folder that may be left when it stops:
673
+ # the database (empty until COMMIT) and SQLite's rollback journal. The
674
+ # four subfolders are allowed too, each empty.
675
+ _LEFTOVER_FILES = (DATABASE_FILE, "data.qda-journal")
676
+ _ICLOUD_SUFFIX = ".icloud"
677
+
678
+
679
+ def _lstat(path: Path) -> Optional[os.stat_result]:
680
+ try:
681
+ return os.lstat(path)
682
+ except OSError:
683
+ return None
684
+
685
+
686
+ def is_unfinished(path: Path) -> bool:
687
+ """Whether `path` is only what a creation that did not finish leaves.
688
+
689
+ Fix round 1 of brief C: a missing or empty `data.qda` alone is not
690
+ enough, since a real project whose database went missing (moved, a
691
+ failed copy, evicted by a sync service, or recreated empty by
692
+ QualCoder's own open) keeps the researcher's recordings and
693
+ documents. Only a folder holding nothing but the four subfolders,
694
+ each empty, a missing or empty `data.qda` and its journal counts.
695
+ Every entry is read with `lstat`, and no link is followed: a link
696
+ anywhere makes the answer no. The database is never opened.
697
+ """
698
+ info = _lstat(path)
699
+ if info is None or not stat.S_ISDIR(info.st_mode):
700
+ return False
701
+ try:
702
+ with os.scandir(path) as listing:
703
+ entries = [(entry.name, entry.path) for entry in listing]
704
+ except OSError:
705
+ return False
706
+ for name, entry_path in entries:
707
+ entry = _lstat(Path(entry_path))
708
+ if entry is None:
709
+ return False
710
+ if name in SUBFOLDERS:
711
+ if not stat.S_ISDIR(entry.st_mode):
712
+ return False
713
+ try:
714
+ with os.scandir(entry_path) as inside:
715
+ if next(inside, None) is not None:
716
+ return False
717
+ except OSError:
718
+ return False
719
+ elif name == DATABASE_FILE:
720
+ if not stat.S_ISREG(entry.st_mode) or entry.st_size != 0:
721
+ return False
722
+ elif name in _LEFTOVER_FILES:
723
+ if not stat.S_ISREG(entry.st_mode):
724
+ return False
725
+ else:
726
+ return False
727
+ return True
728
+
729
+
730
+ def database_state(path: Path) -> Optional[str]:
731
+ """ORPHAN, NO_DATABASE, or None when `path` has a database to open.
732
+
733
+ Read from listings and `lstat` alone, never opening anything: NO
734
+ DATABASE for a folder whose `data.qda` is missing or empty and that
735
+ holds anything a creation does not make. A linked `data.qda` is left
736
+ to the callers, who treat it as unreadable and never open it.
737
+ """
738
+ info = _lstat(path)
739
+ if info is None or not stat.S_ISDIR(info.st_mode):
740
+ return None
741
+ if is_unfinished(path):
742
+ return ORPHAN
743
+ database = _lstat(path / DATABASE_FILE)
744
+ if database is None:
745
+ return NO_DATABASE
746
+ if stat.S_ISREG(database.st_mode) and database.st_size == 0:
747
+ return NO_DATABASE
748
+ return None
749
+
750
+
751
+ def _icloud_placeholders(path: Path) -> List[str]:
752
+ """iCloud's placeholders for files not downloaded ('.<name>.icloud',
753
+ as macOS 13 and earlier leave an evicted file), in the folder and its
754
+ subfolders, one level down."""
755
+ found: List[str] = []
756
+ for folder in (path,) + tuple(path / name for name in SUBFOLDERS):
757
+ info = _lstat(folder)
758
+ if info is None or not stat.S_ISDIR(info.st_mode):
759
+ continue
760
+ try:
761
+ with os.scandir(folder) as listing:
762
+ found += [os.path.relpath(entry.path, path)
763
+ for entry in listing
764
+ if entry.name.startswith(".")
765
+ and entry.name.endswith(_ICLOUD_SUFFIX)]
766
+ except OSError:
767
+ continue
768
+ return sorted(found)
769
+
770
+
771
+ def no_database_note(path: Path) -> str:
772
+ """The neutral words for a folder with no usable database that holds
773
+ more than a creation leaves: no advice to delete, a backup beside it
774
+ and iCloud placeholders named when there are any."""
775
+ text = (f"'{path.name}' holds no usable project database: its data.qda "
776
+ f"is missing or empty. It holds more than a project creation "
777
+ f"leaves, which may be the researcher's own files (QualCoder "
778
+ f"keeps a project's imported recordings and documents in its "
779
+ f"folders) or a project a sync service has not downloaded yet, "
780
+ f"so check what is in it before removing anything.")
781
+ placeholders = _icloud_placeholders(path)
782
+ if placeholders:
783
+ shown = ", ".join(f"'{name}'" for name in placeholders[:3])
784
+ text += (f" It holds iCloud placeholders ({shown}): the project may "
785
+ f"be stored in iCloud and not downloaded to this computer "
786
+ f"yet.")
787
+ try:
788
+ with os.scandir(path.parent) as listing:
789
+ siblings = backup_siblings([e.name for e in listing],
790
+ path.name[:-len(PROJECT_SUFFIX)]
791
+ if path.name.lower().endswith(
792
+ PROJECT_SUFFIX) else path.name)
793
+ except OSError:
794
+ siblings = []
795
+ if siblings:
796
+ text += (f" A backup of it sits beside it ('{siblings[-1]}'), which "
797
+ f"may hold the project.")
798
+ return text
799
+
800
+
801
+ def classify_existing(path: Path) -> str:
802
+ """What an existing entry with the new project's name is.
803
+
804
+ ORPHAN only for what a creation that did not finish leaves
805
+ (`is_unfinished`). NO_DATABASE for any other folder whose `data.qda`
806
+ is missing or empty. PROJECT for a readable database with a project
807
+ row. UNREADABLE for anything else in a folder, which includes a real
808
+ project that another program was writing when it stopped (a full
809
+ `data.qda` with a journal beside it) and a `data.qda` that is a link,
810
+ which is never followed. The database is opened read-only, so a
811
+ stranger's journal is never rolled back; NOT_A_FOLDER for a file or a
812
+ link.
813
+ """
814
+ info = _lstat(path)
815
+ if info is None or not stat.S_ISDIR(info.st_mode):
816
+ return NOT_A_FOLDER
817
+ state = database_state(path)
818
+ if state is not None:
819
+ return state
820
+ database = path / DATABASE_FILE
821
+ entry = _lstat(database)
822
+ if entry is None or not stat.S_ISREG(entry.st_mode):
823
+ return UNREADABLE # a link, a folder, a device: not opened
824
+ conn = None
825
+ try:
826
+ conn = sqlite3.connect(_sqlite_ro_uri(database), uri=True)
827
+ row = conn.execute("SELECT count(*) FROM project").fetchone()
828
+ return PROJECT if row and row[0] else UNREADABLE
829
+ except sqlite3.Error:
830
+ return UNREADABLE
831
+ finally:
832
+ if conn is not None:
833
+ conn.close()
834
+
835
+
836
+ def existing_name_refusal(entry: str, folder_name: str, kind: str,
837
+ folder: Optional[Path] = None) -> str:
838
+ """The refusal for an entry that already holds the new folder's name
839
+ (`folder` is its path, for the words a NO_DATABASE folder gets)."""
840
+ if entry != folder_name:
841
+ return (f"The folder already holds '{entry}', which is the same "
842
+ f"name as '{folder_name}' on a disk that ignores letter "
843
+ f"case, accent composition or a trailing dot or space "
844
+ f"(macOS and Windows by default). Two projects under those "
845
+ f"names would become one folder when copied to such a "
846
+ f"disk, and one of them would be lost. Choose another "
847
+ f"name.")
848
+ if kind == PROJECT:
849
+ return (f"A project called '{folder_name}' already exists there. "
850
+ f"Select it with select_project to work on it, or choose "
851
+ f"another name.")
852
+ if kind == ORPHAN:
853
+ return (f"A folder called '{folder_name}' exists there with no "
854
+ f"usable project database in it: it looks like the remains "
855
+ f"of a project creation that did not finish, and nothing "
856
+ f"in it can be opened. The researcher may delete it by "
857
+ f"hand; otherwise choose another name. This tool never "
858
+ f"deletes an existing folder.")
859
+ if kind == NO_DATABASE and folder is not None:
860
+ return (f"A folder called '{folder_name}' exists there. "
861
+ + no_database_note(folder) + " Choose another name.")
862
+ if kind == NOT_A_FOLDER:
863
+ return (f"Something called '{folder_name}' exists there that is "
864
+ f"not a project folder (a file or a link). Choose another "
865
+ f"name.")
866
+ return (f"A folder called '{folder_name}' exists there and its database "
867
+ f"could not be read. Choose another name.")
868
+
869
+
870
+ def backup_siblings(entries: Sequence[str], stem: str) -> List[str]:
871
+ """Entries named like the new project's backups: '<stem>_backup_...'
872
+ or '<stem>_BKUP_...', ending in '.qda'. Compared with letter case
873
+ folded, as a Windows disk and Python's own matching there do."""
874
+ prefixes = tuple(f"{stem}{marker}".casefold()
875
+ for marker in BACKUP_MARKERS)
876
+ return sorted(entry for entry in entries
877
+ if entry.casefold().startswith(prefixes)
878
+ and entry.casefold().endswith(PROJECT_SUFFIX))
879
+
880
+
881
+ def backup_siblings_refusal(found: Sequence[str], stem: str) -> str:
882
+ shown = ", ".join(f"'{name}'" for name in found[:5])
883
+ more = f" and {len(found) - 5} more" if len(found) > 5 else ""
884
+ return (f"The folder already holds {shown}{more}, named like backups "
885
+ f"of a project called '{stem}'. This server's backup tools "
886
+ f"would offer them as the new project's own backups, and could "
887
+ f"restore one in its place; QualCoder 4.0 deletes '_BKUP_' "
888
+ f"folders of a project beyond the newest five when it closes "
889
+ f"it. Choose another name, or move those folders first.")
890
+
891
+
892
+ def scan_parent(parent: Path, folder_name: str,
893
+ stem: str) -> Optional[str]:
894
+ """The refusal the folder's present contents call for, or None.
895
+
896
+ A parent that does not exist yet (the workspace before its first
897
+ project) holds nothing. The same name is found by
898
+ `documents_name_key` (NFC, letter case folded, trailing dots and
899
+ spaces dropped): the atomic `mkdir` stays the final check, but on a
900
+ case-sensitive disk it would let 'study.qda' beside 'Study.qda'.
901
+ """
902
+ if not parent.exists():
903
+ return None
904
+ try:
905
+ entries = [entry.name for entry in os.scandir(parent)]
906
+ except OSError as error:
907
+ raise Refusal(
908
+ f"The folder '{parent}' could not be read "
909
+ f"({type(error).__name__}); check that it exists and that "
910
+ f"this program may read it.") from None
911
+ key = documents_name_key(folder_name)
912
+ same = [entry for entry in entries if documents_name_key(entry) == key]
913
+ if same:
914
+ entry = folder_name if folder_name in same else sorted(same)[0]
915
+ kind = classify_existing(parent / entry) if entry == folder_name \
916
+ else ""
917
+ return existing_name_refusal(entry, folder_name, kind,
918
+ parent / entry)
919
+ siblings = backup_siblings(entries, stem)
920
+ if siblings:
921
+ return backup_siblings_refusal(siblings, stem)
922
+ return None
923
+
924
+
925
+ # ---------------------------------------------------------------------------
926
+ # Path lengths. Windows, unless long paths are switched on (it leaves them
927
+ # off by default), cannot make a folder at a path of more than 247
928
+ # characters (MAX_PATH less room for a short name) or open a file at more
929
+ # than 259 (MAX_PATH less the closing null). A project is refused on
930
+ # Windows when it could not be made there; everywhere, a path that leaves
931
+ # little room for the names of imported files draws a warning, since a
932
+ # project may be moved to Windows.
933
+ # ---------------------------------------------------------------------------
934
+
935
+ WINDOWS_MAX_FOLDER_PATH = 247
936
+ WINDOWS_MAX_FILE_PATH = 259
937
+ # The room a file name inside documents/ should have (file names may be
938
+ # up to 200 bytes; 100 characters holds most real ones).
939
+ FILE_NAME_ROOM = 100
940
+ _LONGEST_SUBFOLDER = max(SUBFOLDERS, key=len)
941
+ _LONGEST_DATABASE_FILE = max(_DATABASE_SIDE_FILES + (DATABASE_FILE,),
942
+ key=len)
943
+
944
+
945
+ def windows_long_paths_enabled() -> bool:
946
+ """Whether Windows has long paths switched on (False elsewhere, and
947
+ False when the setting cannot be read)."""
948
+ if os.name != "nt":
949
+ return False
950
+ try:
951
+ import winreg
952
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
953
+ r"SYSTEM\CurrentControlSet\Control\FileSystem"
954
+ ) as key:
955
+ value, _ = winreg.QueryValueEx(key, "LongPathsEnabled")
956
+ return value == 1
957
+ except (ImportError, OSError):
958
+ return False
959
+
960
+
961
+ def path_lengths(folder: Path) -> Tuple[int, int]:
962
+ """(the longest folder path creation makes, the longest file path)."""
963
+ return (len(str(folder / _LONGEST_SUBFOLDER)),
964
+ len(str(folder / _LONGEST_DATABASE_FILE)))
965
+
966
+
967
+ def windows_path_refusal(folder: Path, on_windows: bool,
968
+ long_paths: bool) -> Optional[str]:
969
+ """The refusal for a project Windows could not make, or None."""
970
+ if not on_windows or long_paths:
971
+ return None
972
+ longest_folder, longest_file = path_lengths(folder)
973
+ if (longest_folder <= WINDOWS_MAX_FOLDER_PATH
974
+ and longest_file <= WINDOWS_MAX_FILE_PATH):
975
+ return None
976
+ return (f"The project's paths would be too long for Windows: its "
977
+ f"'{_LONGEST_SUBFOLDER}' folder would be {longest_folder} "
978
+ f"characters and its database's journal {longest_file}, and "
979
+ f"Windows cannot make a folder beyond "
980
+ f"{WINDOWS_MAX_FOLDER_PATH} characters or open a file beyond "
981
+ f"{WINDOWS_MAX_FILE_PATH} unless long paths are switched on. "
982
+ f"Choose a shorter name, or a folder nearer the top of the "
983
+ f"disk.")
984
+
985
+
986
+ # SQLite's own limit on a file's path, in bytes, on macOS and Linux
987
+ # (MAX_PATHNAME in its unix layer): the journal's path, the longest it
988
+ # makes, must fit. Measured here with SQLite 3.49.1 and 3.50.4: a
989
+ # data.qda path of 504 bytes opens, 505 does not.
990
+ SQLITE_MAX_PATH_BYTES = 512
991
+
992
+
993
+ def sqlite_path_refusal(folder: Path, on_windows: bool) -> Optional[str]:
994
+ """The refusal for a project whose database SQLite could not open
995
+ because its path is too long (not on Windows, whose limits are
996
+ `windows_path_refusal`'s), or None."""
997
+ if on_windows:
998
+ return None
999
+ size = len(os.fsencode(str(folder / _LONGEST_DATABASE_FILE)))
1000
+ if size <= SQLITE_MAX_PATH_BYTES:
1001
+ return None
1002
+ return (f"The project's database path would be too long for SQLite: "
1003
+ f"its journal's path would be {size} bytes, and SQLite cannot "
1004
+ f"open a file whose path passes {SQLITE_MAX_PATH_BYTES}. Choose "
1005
+ f"a shorter name, or a folder nearer the top of the disk.")
1006
+
1007
+
1008
+ def file_name_room(folder: Path) -> int:
1009
+ """How many characters a file name inside documents/ may have before
1010
+ its path passes Windows' 259."""
1011
+ return WINDOWS_MAX_FILE_PATH - len(str(folder / "documents")) - 1
1012
+
1013
+
1014
+ def long_path_warning(folder: Path) -> Optional[str]:
1015
+ """A warning when the project's path leaves little room for the names
1016
+ of the files imported into it, or None."""
1017
+ room = file_name_room(folder)
1018
+ if room >= FILE_NAME_ROOM:
1019
+ return None
1020
+ return (f"This project's folder path is {len(str(folder))} characters "
1021
+ f"long, which leaves room for file names of only {max(room, 0)} "
1022
+ f"characters inside it before a path passes the "
1023
+ f"{WINDOWS_MAX_FILE_PATH} characters Windows allows (unless "
1024
+ f"long paths are switched on there). Importing a file with a "
1025
+ f"longer name, or opening the project on Windows, could fail. "
1026
+ f"Short names, in folders near the top of the disk, travel "
1027
+ f"better.")