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/__init__.py +16 -0
- exegete/coder_comparison.py +208 -0
- exegete/cursors.py +218 -0
- exegete/database.py +12621 -0
- exegete/env_settings.py +174 -0
- exegete/memo_privacy.py +215 -0
- exegete/names.py +68 -0
- exegete/new_project.py +1027 -0
- exegete/preview_tokens.py +613 -0
- exegete/project_settings.py +1082 -0
- exegete/pseudonymise.py +2796 -0
- exegete/refi_export.py +645 -0
- exegete/server.py +18899 -0
- exegete/sessions.py +1153 -0
- exegete/state_folder.py +360 -0
- exegete/transition.py +1049 -0
- exegete-0.14.1a0.dev1.dist-info/METADATA +515 -0
- exegete-0.14.1a0.dev1.dist-info/RECORD +24 -0
- exegete-0.14.1a0.dev1.dist-info/WHEEL +5 -0
- exegete-0.14.1a0.dev1.dist-info/entry_points.txt +2 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/COPYING.LESSER +165 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/NOTICE +773 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/legal/GPL-3.0.txt +674 -0
- exegete-0.14.1a0.dev1.dist-info/top_level.txt +1 -0
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.")
|