python-pages 0.1.0__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.
pages/tables.py ADDED
@@ -0,0 +1,493 @@
1
+ """Middle-layer reader and conservative writer for Pages TST tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, fields
6
+ import struct
7
+ from typing import TYPE_CHECKING
8
+ import uuid
9
+
10
+ from .components import GraphClone, clone_object_graph, replace_uuid_identities
11
+ from .protobuf import Message
12
+ from .stylesheet import CharacterProperties, character_style_properties
13
+ from .text import LocatedObject, parse_attribute_table, parse_reference
14
+
15
+ if TYPE_CHECKING:
16
+ from .pages import PagesDocument
17
+
18
+
19
+ TYPE_TABLE_INFO = 6000
20
+ TYPE_TABLE_MODEL = 6001
21
+ TYPE_TILE = 6002
22
+ TYPE_TABLE_DATA_LIST = 6005
23
+ TYPE_ATTACHMENT = 2003
24
+ TYPE_HEADER_STORAGE_BUCKET = 6006
25
+ TYPE_COLUMN_ROW_UID_MAP = 6267
26
+ TYPE_STROKE_SIDECAR = 6305
27
+
28
+ STORAGE_TABLE_ATTACHMENTS = 9
29
+
30
+ TABLE_INFO_MODEL = 2
31
+ TABLE_MODEL_DATA_STORE = 4
32
+ TABLE_MODEL_ROWS = 6
33
+ TABLE_MODEL_COLUMNS = 7
34
+ TABLE_MODEL_NAME = 8
35
+ TABLE_MODEL_HEADER_ROWS = 9
36
+ TABLE_MODEL_HEADER_COLUMNS = 10
37
+ TABLE_MODEL_FOOTER_ROWS = 11
38
+ TABLE_MODEL_BODY_TEXT_STYLE = 24
39
+ TABLE_MODEL_HEADER_ROW_TEXT_STYLE = 25
40
+ TABLE_MODEL_HEADER_COLUMN_TEXT_STYLE = 26
41
+ TABLE_MODEL_FOOTER_ROW_TEXT_STYLE = 27
42
+ TABLE_MODEL_COLUMN_ROW_UIDS = 46
43
+ TABLE_MODEL_STROKE_SIDECAR = 49
44
+
45
+ DATA_STORE_ROW_HEADERS = 1
46
+ DATA_STORE_COLUMN_HEADERS = 2
47
+ DATA_STORE_TILES = 3
48
+ DATA_STORE_STRING_TABLE = 4
49
+ DATA_STORE_STYLE_TABLE = 5
50
+
51
+ DATA_LIST_TYPE = 1
52
+ DATA_LIST_NEXT_KEY = 2
53
+ DATA_LIST_ENTRIES = 3
54
+ DATA_LIST_STRING = 1
55
+ DATA_LIST_STYLE = 4
56
+ LIST_ENTRY_KEY = 1
57
+ LIST_ENTRY_REFCOUNT = 2
58
+ LIST_ENTRY_STRING = 3
59
+ LIST_ENTRY_REFERENCE = 4
60
+
61
+ CELL_STORAGE_VERSION = 5
62
+ CELL_TYPE_TEXT = 3
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class CellStorage:
67
+ """The portions of a version-5 TST cell buffer used by this API."""
68
+
69
+ raw: bytes
70
+ cell_type: int
71
+ string_id: int | None = None
72
+ rich_text_id: int | None = None
73
+ cell_style_id: int | None = None
74
+ text_style_id: int | None = None
75
+
76
+
77
+ def _reference(message: Message, number: int) -> int | None:
78
+ field = message.one(number)
79
+ return None if field is None else parse_reference(bytes(field.value))
80
+
81
+
82
+ def _parse_cell_storage(data: bytes) -> CellStorage:
83
+ if len(data) < 12:
84
+ raise ValueError("TST cell storage is shorter than its 12-byte header")
85
+ if data[0] != CELL_STORAGE_VERSION:
86
+ raise ValueError(f"unsupported TST cell storage version {data[0]}")
87
+ flags = struct.unpack_from("<I", data, 8)[0]
88
+ offset = 12
89
+ identifiers: dict[int, int] = {}
90
+ # Every active flag contributes one fixed-width payload in increasing flag
91
+ # order. Numeric/date values precede the keyed identifiers.
92
+ widths = {
93
+ 0x1: 16,
94
+ 0x2: 8,
95
+ 0x4: 8,
96
+ **{1 << bit: 4 for bit in range(3, 21)},
97
+ }
98
+ for flag, width in widths.items():
99
+ if not flags & flag:
100
+ continue
101
+ if offset + width > len(data):
102
+ raise ValueError("truncated TST cell storage flag payload")
103
+ if width == 4:
104
+ identifiers[flag] = struct.unpack_from("<i", data, offset)[0]
105
+ offset += width
106
+ return CellStorage(
107
+ raw=data,
108
+ cell_type=data[1],
109
+ string_id=identifiers.get(0x8),
110
+ rich_text_id=identifiers.get(0x10),
111
+ cell_style_id=identifiers.get(0x20),
112
+ text_style_id=identifiers.get(0x40),
113
+ )
114
+
115
+
116
+ def _row_buffers(row_info: Message, columns: int) -> list[bytes | None]:
117
+ storage = row_info.one(6)
118
+ offsets_field = row_info.one(7)
119
+ if storage is None or offsets_field is None:
120
+ raise ValueError("pre-BNC TST table rows are unsupported")
121
+ offsets_raw = bytes(offsets_field.value)
122
+ if len(offsets_raw) % 2:
123
+ raise ValueError("TST cell offset array has an odd byte length")
124
+ offsets = list(struct.unpack(f"<{len(offsets_raw) // 2}h", offsets_raw))
125
+ wide = row_info.one(8)
126
+ if wide is not None and bool(wide.value):
127
+ offsets = [value * 4 for value in offsets]
128
+ payload = bytes(storage.value)
129
+ result: list[bytes | None] = []
130
+ for column in range(columns):
131
+ if column >= len(offsets) or offsets[column] < 0:
132
+ result.append(None)
133
+ continue
134
+ start = offsets[column]
135
+ end = next(
136
+ (value for value in offsets[column + 1 :] if value >= 0),
137
+ len(payload),
138
+ )
139
+ result.append(payload[start:end])
140
+ return result
141
+
142
+
143
+ class TableStore:
144
+ """Resolved TableInfo/TableModel/DataStore graph for one body table."""
145
+
146
+ def __init__(self, document: "PagesDocument", info: LocatedObject):
147
+ self.document = document
148
+ self.info = info
149
+ model_id = _reference(info.message, TABLE_INFO_MODEL)
150
+ model = document.objects().get(model_id) if model_id is not None else None
151
+ if model is None or model.info.type_id != TYPE_TABLE_MODEL:
152
+ raise ValueError(f"TST.TableInfoArchive {info.segment.identifier} has no table model")
153
+ self.model = model
154
+ data_store = model.message.one(TABLE_MODEL_DATA_STORE)
155
+ if data_store is None:
156
+ raise ValueError(f"TST.TableModelArchive {model_id} has no DataStore")
157
+ self.data_store = Message.parse(bytes(data_store.value))
158
+ self.rows = int(model.message.one(TABLE_MODEL_ROWS).value)
159
+ self.columns = int(model.message.one(TABLE_MODEL_COLUMNS).value)
160
+ name = model.message.one(TABLE_MODEL_NAME)
161
+ self.name = bytes(name.value).decode("utf-8", "replace") if name else ""
162
+ self._cells = self._load_cells()
163
+
164
+ def _load_cells(self) -> dict[tuple[int, int], CellStorage]:
165
+ tiles_field = self.data_store.one(DATA_STORE_TILES)
166
+ if tiles_field is None:
167
+ return {}
168
+ tiles = Message.parse(bytes(tiles_field.value))
169
+ tile_size_field = tiles.one(2)
170
+ tile_size = int(tile_size_field.value) if tile_size_field is not None else 256
171
+ result: dict[tuple[int, int], CellStorage] = {}
172
+ objects = self.document.objects()
173
+ for tile_field in tiles.get(1):
174
+ tile_link = Message.parse(bytes(tile_field.value))
175
+ tile_id_field = tile_link.one(1)
176
+ tile_id = int(tile_id_field.value) if tile_id_field is not None else 0
177
+ object_id = _reference(tile_link, 2)
178
+ tile = objects.get(object_id) if object_id is not None else None
179
+ if tile is None or tile.info.type_id != TYPE_TILE:
180
+ continue
181
+ for row_field in tile.message.get(5):
182
+ row_info = Message.parse(bytes(row_field.value))
183
+ row_index_field = row_info.one(1)
184
+ if row_index_field is None:
185
+ continue
186
+ row = tile_id * tile_size + int(row_index_field.value)
187
+ for column, raw in enumerate(_row_buffers(row_info, self.columns)):
188
+ if raw is not None:
189
+ result[(row, column)] = _parse_cell_storage(raw)
190
+ return result
191
+
192
+ def cell_storage(self, row: int, column: int) -> CellStorage | None:
193
+ return self._cells.get((row, column))
194
+
195
+ def _data_list(self, field_number: int, expected_type: int) -> LocatedObject:
196
+ object_id = _reference(self.data_store, field_number)
197
+ obj = self.document.objects().get(object_id) if object_id is not None else None
198
+ if obj is None or obj.info.type_id != TYPE_TABLE_DATA_LIST:
199
+ raise ValueError(f"table DataStore field {field_number} has no TableDataList")
200
+ list_type = obj.message.one(DATA_LIST_TYPE)
201
+ if list_type is None or int(list_type.value) != expected_type:
202
+ raise ValueError(f"TableDataList {object_id} has unexpected list type")
203
+ return obj
204
+
205
+ @staticmethod
206
+ def _entry(data_list: LocatedObject, key: int) -> tuple[object, Message]:
207
+ for raw_entry in data_list.message.get(DATA_LIST_ENTRIES):
208
+ entry = Message.parse(bytes(raw_entry.value))
209
+ key_field = entry.one(LIST_ENTRY_KEY)
210
+ if key_field is not None and int(key_field.value) == key:
211
+ return raw_entry, entry
212
+ raise KeyError(f"TableDataList {data_list.segment.identifier} has no key {key}")
213
+
214
+ def string(self, key: int) -> str:
215
+ data_list = self._data_list(DATA_STORE_STRING_TABLE, DATA_LIST_STRING)
216
+ _, entry = self._entry(data_list, key)
217
+ value = entry.one(LIST_ENTRY_STRING)
218
+ return "" if value is None else bytes(value.value).decode("utf-8", "replace")
219
+
220
+ def set_string(self, key: int, value: str) -> None:
221
+ data_list = self._data_list(DATA_STORE_STRING_TABLE, DATA_LIST_STRING)
222
+ raw_entry, entry = self._entry(data_list, key)
223
+ refcount = entry.one(LIST_ENTRY_REFCOUNT)
224
+ if refcount is not None and int(refcount.value) != 1:
225
+ raise NotImplementedError("shared TST string entries require cell-key reassignment")
226
+ string = entry.one(LIST_ENTRY_STRING)
227
+ if string is None:
228
+ entry.add_bytes(LIST_ENTRY_STRING, value.encode("utf-8"))
229
+ else:
230
+ string.set_bytes(value.encode("utf-8"))
231
+ raw_entry.set_bytes(entry.encode())
232
+
233
+ def text_style(self, row: int, column: int, cell: CellStorage | None) -> LocatedObject | None:
234
+ style_key = cell.text_style_id if cell is not None else None
235
+ if style_key is not None:
236
+ styles = self._data_list(DATA_STORE_STYLE_TABLE, DATA_LIST_STYLE)
237
+ _, entry = self._entry(styles, style_key)
238
+ object_id = _reference(entry, LIST_ENTRY_REFERENCE)
239
+ else:
240
+ header_rows = self.model.message.one(TABLE_MODEL_HEADER_ROWS)
241
+ header_columns = self.model.message.one(TABLE_MODEL_HEADER_COLUMNS)
242
+ footer_rows = self.model.message.one(TABLE_MODEL_FOOTER_ROWS)
243
+ if header_rows is not None and row < int(header_rows.value):
244
+ field_number = TABLE_MODEL_HEADER_ROW_TEXT_STYLE
245
+ elif header_columns is not None and column < int(header_columns.value):
246
+ field_number = TABLE_MODEL_HEADER_COLUMN_TEXT_STYLE
247
+ elif footer_rows is not None and row >= self.rows - int(footer_rows.value):
248
+ field_number = TABLE_MODEL_FOOTER_ROW_TEXT_STYLE
249
+ else:
250
+ field_number = TABLE_MODEL_BODY_TEXT_STYLE
251
+ object_id = _reference(self.model.message, field_number)
252
+ return self.document.objects().get(object_id) if object_id is not None else None
253
+
254
+ def character_properties(
255
+ self, row: int, column: int, cell: CellStorage | None
256
+ ) -> CharacterProperties:
257
+ style = self.text_style(row, column, cell)
258
+ chain: list[CharacterProperties] = []
259
+ seen: set[int] = set()
260
+ while style is not None and style.segment.identifier not in seen:
261
+ seen.add(style.segment.identifier)
262
+ chain.append(character_style_properties(style))
263
+ super_field = style.message.one(1)
264
+ if super_field is None:
265
+ break
266
+ super_message = Message.parse(bytes(super_field.value))
267
+ parent_id = _reference(super_message, 3)
268
+ style = self.document.objects().get(parent_id) if parent_id is not None else None
269
+ values = {}
270
+ for item in fields(CharacterProperties):
271
+ value = next(
272
+ (getattr(props, item.name) for props in chain if getattr(props, item.name) is not None),
273
+ None,
274
+ )
275
+ values[item.name] = value
276
+ return CharacterProperties(**values)
277
+
278
+
279
+ def body_table_stores(document: "PagesDocument", storage: LocatedObject) -> list[TableStore]:
280
+ """Resolve body attachment runs to their TST table graphs in document order."""
281
+ field = storage.message.one(STORAGE_TABLE_ATTACHMENTS)
282
+ if field is None:
283
+ return []
284
+ objects = document.objects()
285
+ result: list[TableStore] = []
286
+ seen: set[int] = set()
287
+ for run in parse_attribute_table(bytes(field.value)):
288
+ attachment = objects.get(run.object_id) if run.object_id is not None else None
289
+ if attachment is None or attachment.info.type_id != TYPE_ATTACHMENT:
290
+ continue
291
+ info_id = _reference(attachment.message, 1)
292
+ info = objects.get(info_id) if info_id is not None else None
293
+ if info is None or info.info.type_id != TYPE_TABLE_INFO or info_id in seen:
294
+ continue
295
+ seen.add(info_id)
296
+ result.append(TableStore(document, info))
297
+ return result
298
+
299
+
300
+ def _body_table_attachment(
301
+ document: "PagesDocument", storage: LocatedObject
302
+ ) -> LocatedObject:
303
+ field = storage.message.one(STORAGE_TABLE_ATTACHMENTS)
304
+ objects = document.objects()
305
+ if field is not None:
306
+ for run in parse_attribute_table(bytes(field.value)):
307
+ attachment = objects.get(run.object_id) if run.object_id is not None else None
308
+ if attachment is None or attachment.info.type_id != TYPE_ATTACHMENT:
309
+ continue
310
+ info_id = _reference(attachment.message, 1)
311
+ info = objects.get(info_id) if info_id is not None else None
312
+ if info is not None and info.info.type_id == TYPE_TABLE_INFO:
313
+ return attachment
314
+ raise ValueError("document has no Pages-authored table graph to use as a template")
315
+
316
+
317
+ def _set_varint(message: Message, number: int, value: int) -> None:
318
+ field = message.one(number)
319
+ if field is None:
320
+ message.add_varint(number, value)
321
+ else:
322
+ field.set_varint(value)
323
+
324
+
325
+ def _resize_header_bucket(
326
+ bucket: Message, count: int, cells_per_header: int
327
+ ) -> None:
328
+ source = bucket.get(2)
329
+ if not source:
330
+ return
331
+ template = Message.parse(bytes(source[0].value))
332
+ bucket.fields = [field for field in bucket.fields if field.number != 2]
333
+ for index in range(count):
334
+ header = Message.parse(template.encode())
335
+ _set_varint(header, 1, index)
336
+ _set_varint(header, 4, cells_per_header)
337
+ bucket.add_bytes(2, header.encode())
338
+
339
+
340
+ def _uuid_message(value: uuid.UUID) -> bytes:
341
+ message = Message()
342
+ message.add_varint(1, value.int >> 64)
343
+ message.add_varint(2, value.int & ((1 << 64) - 1))
344
+ return message.encode()
345
+
346
+
347
+ def _rebuild_uid_map(
348
+ uid_map: Message, rows: int, columns: int, *, seed: str
349
+ ) -> None:
350
+ """Replace the cloned 2x2 row/column UID index with the requested grid."""
351
+
352
+ def dimension(count: int, label: str) -> tuple[list[bytes], list[int], list[int]]:
353
+ indexed = [
354
+ (index, uuid.uuid5(uuid.NAMESPACE_URL, f"{seed}:{label}:{index}"))
355
+ for index in range(count)
356
+ ]
357
+ sorted_values = sorted(indexed, key=lambda item: item[1].int)
358
+ index_for_uid = [index for index, _ in sorted_values]
359
+ uid_for_index = [0] * count
360
+ for uid_index, (original_index, _) in enumerate(sorted_values):
361
+ uid_for_index[original_index] = uid_index
362
+ return (
363
+ [_uuid_message(value) for _, value in sorted_values],
364
+ index_for_uid,
365
+ uid_for_index,
366
+ )
367
+
368
+ columns_sorted, column_indexes, column_uids = dimension(columns, "column")
369
+ rows_sorted, row_indexes, row_uids = dimension(rows, "row")
370
+ uid_map.fields = []
371
+ for encoded in columns_sorted:
372
+ uid_map.add_bytes(1, encoded)
373
+ for value in column_indexes:
374
+ uid_map.add_varint(2, value)
375
+ for value in column_uids:
376
+ uid_map.add_varint(3, value)
377
+ for encoded in rows_sorted:
378
+ uid_map.add_bytes(4, encoded)
379
+ for value in row_indexes:
380
+ uid_map.add_varint(5, value)
381
+ for value in row_uids:
382
+ uid_map.add_varint(6, value)
383
+
384
+
385
+ def _clear_tile(tile: Message, rows: int, columns: int) -> None:
386
+ tile.fields = [field for field in tile.fields if field.number != 5]
387
+ for number, value in ((1, 0), (2, 0), (3, 0), (4, rows), (6, 5), (7, 1), (8, 1)):
388
+ _set_varint(tile, number, value)
389
+ offsets = struct.pack(f"<{columns}h", *([-1] * columns))
390
+ for row_index in range(rows):
391
+ row = Message()
392
+ row.add_varint(1, row_index)
393
+ row.add_varint(2, 0)
394
+ row.add_bytes(3, "🤠".encode("utf-8"))
395
+ row.add_bytes(4, "🤠".encode("utf-8"))
396
+ row.add_varint(5, 5)
397
+ row.add_bytes(6, b"")
398
+ row.add_bytes(7, offsets)
399
+ row.add_varint(8, 1)
400
+ tile.add_bytes(5, row.encode())
401
+
402
+
403
+ def clone_body_table(
404
+ document: "PagesDocument", storage: LocatedObject, rows: int, columns: int
405
+ ) -> tuple[TableStore, GraphClone]:
406
+ """Clone the fixture-authored complete TST graph as a blank body table."""
407
+ template_attachment = _body_table_attachment(document, storage)
408
+
409
+ def include(obj: LocatedObject) -> bool:
410
+ return obj.filename.startswith("Index/CalculationEngine-") or obj.filename.startswith(
411
+ "Index/Tables/"
412
+ )
413
+
414
+ clone = clone_object_graph(
415
+ document,
416
+ template_attachment.segment.identifier,
417
+ include=include,
418
+ )
419
+ objects = document.objects()
420
+ info_id = _reference(clone.root.message, 1)
421
+ info = objects.get(info_id) if info_id is not None else None
422
+ if info is None or info.info.type_id != TYPE_TABLE_INFO:
423
+ raise ValueError("cloned table attachment has no TableInfoArchive")
424
+ model_id = _reference(info.message, TABLE_INFO_MODEL)
425
+ model = objects.get(model_id) if model_id is not None else None
426
+ if model is None or model.info.type_id != TYPE_TABLE_MODEL:
427
+ raise ValueError("cloned TableInfoArchive has no TableModelArchive")
428
+
429
+ seed = f"python-pages:table:{info.segment.identifier}:{model.segment.identifier}"
430
+ for new_identifier in clone.identifiers.values():
431
+ replace_uuid_identities(objects[new_identifier].message, seed=seed)
432
+ table_id = model.message.one(1)
433
+ generated_table_id = str(uuid.uuid5(uuid.NAMESPACE_URL, seed)).upper().encode("ascii")
434
+ if table_id is None:
435
+ model.message.add_bytes(1, generated_table_id)
436
+ else:
437
+ table_id.set_bytes(generated_table_id)
438
+ _set_varint(model.message, TABLE_MODEL_ROWS, rows)
439
+ _set_varint(model.message, TABLE_MODEL_COLUMNS, columns)
440
+ name = model.message.one(TABLE_MODEL_NAME)
441
+ table_number = len(body_table_stores(document, storage)) + 1
442
+ encoded_name = f"Table {table_number}".encode("utf-8")
443
+ if name is None:
444
+ model.message.add_bytes(TABLE_MODEL_NAME, encoded_name)
445
+ else:
446
+ name.set_bytes(encoded_name)
447
+
448
+ data_store_field = model.message.one(TABLE_MODEL_DATA_STORE)
449
+ if data_store_field is None:
450
+ raise ValueError("cloned TableModelArchive has no DataStore")
451
+ data_store = Message.parse(bytes(data_store_field.value))
452
+ row_headers_field = data_store.one(DATA_STORE_ROW_HEADERS)
453
+ if row_headers_field is None:
454
+ raise ValueError("cloned table DataStore has no row HeaderStorage")
455
+ row_headers = Message.parse(bytes(row_headers_field.value))
456
+ row_bucket_ids = [
457
+ parse_reference(bytes(field.value)) for field in row_headers.get(2)
458
+ ]
459
+ column_bucket_id = _reference(data_store, DATA_STORE_COLUMN_HEADERS)
460
+ for row_bucket_id in row_bucket_ids:
461
+ bucket = objects.get(row_bucket_id) if row_bucket_id is not None else None
462
+ if bucket is None or bucket.info.type_id != TYPE_HEADER_STORAGE_BUCKET:
463
+ raise ValueError("cloned table row HeaderStorageBucket is missing")
464
+ _resize_header_bucket(bucket.message, rows, columns)
465
+ column_bucket = (
466
+ objects.get(column_bucket_id) if column_bucket_id is not None else None
467
+ )
468
+ if column_bucket is None or column_bucket.info.type_id != TYPE_HEADER_STORAGE_BUCKET:
469
+ raise ValueError("cloned table column HeaderStorageBucket is missing")
470
+ _resize_header_bucket(column_bucket.message, columns, rows)
471
+
472
+ uid_map_id = _reference(model.message, TABLE_MODEL_COLUMN_ROW_UIDS)
473
+ uid_map = objects.get(uid_map_id) if uid_map_id is not None else None
474
+ if uid_map is None or uid_map.info.type_id != TYPE_COLUMN_ROW_UID_MAP:
475
+ raise ValueError("cloned TableModelArchive has no ColumnRowUIDMapArchive")
476
+ _rebuild_uid_map(uid_map.message, rows, columns, seed=seed)
477
+
478
+ sidecar_id = _reference(model.message, TABLE_MODEL_STROKE_SIDECAR)
479
+ sidecar = objects.get(sidecar_id) if sidecar_id is not None else None
480
+ if sidecar is None or sidecar.info.type_id != TYPE_STROKE_SIDECAR:
481
+ raise ValueError("cloned TableModelArchive has no StrokeSidecarArchive")
482
+ _set_varint(sidecar.message, 2, columns)
483
+ _set_varint(sidecar.message, 3, rows)
484
+
485
+ # Every owned tile is made blank. Retaining the Pages-authored list/style
486
+ # components is intentional: the empty tile no longer references their
487
+ # entries, but the complete editing graph remains available to Pages.
488
+ for old, new in clone.identifiers.items():
489
+ obj = objects[new]
490
+ if obj.info.type_id == TYPE_TILE:
491
+ _clear_tile(obj.message, rows, columns)
492
+
493
+ return TableStore(document, info), clone