iphone2android 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {iphone2android-0.2.0/src/iphone2android.egg-info → iphone2android-0.3.0}/PKG-INFO +17 -3
- {iphone2android-0.2.0 → iphone2android-0.3.0}/README.md +16 -2
- {iphone2android-0.2.0 → iphone2android-0.3.0}/pyproject.toml +2 -2
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/__init__.py +1 -1
- iphone2android-0.3.0/src/iphone2android/accounts.py +33 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/adb.py +19 -0
- iphone2android-0.3.0/src/iphone2android/android/build.py +209 -0
- iphone2android-0.3.0/src/iphone2android/android/launcher.py +351 -0
- iphone2android-0.3.0/src/iphone2android/android/profile.py +51 -0
- iphone2android-0.3.0/src/iphone2android/android/profiles/coloros-16.json +197 -0
- iphone2android-0.3.0/src/iphone2android/android/profiles/generic.json +158 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/ui.py +9 -6
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/appdata.py +3 -1
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/audit.py +16 -4
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/backup.py +1 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/cli.py +139 -14
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/media.py +10 -1
- iphone2android-0.3.0/src/iphone2android/skill/SKILL.md +247 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0/src/iphone2android.egg-info}/PKG-INFO +17 -3
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/SOURCES.txt +6 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/tests/test_android.py +0 -104
- iphone2android-0.3.0/tests/test_builder.py +102 -0
- iphone2android-0.3.0/tests/test_launcher_driver.py +97 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/tests/test_v2.py +26 -24
- iphone2android-0.2.0/src/iphone2android/android/build.py +0 -129
- iphone2android-0.2.0/src/iphone2android/android/launcher.py +0 -134
- iphone2android-0.2.0/src/iphone2android/skill/SKILL.md +0 -144
- {iphone2android-0.2.0 → iphone2android-0.3.0}/LICENSE +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/setup.cfg +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/__main__.py +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/__init__.py +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/debloat.py +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/play.py +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/apps.py +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/convert.py +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/layout.py +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/wallpaper.py +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/dependency_links.txt +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/entry_points.txt +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/requires.txt +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/top_level.txt +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/tests/test_backup_media.py +0 -0
- {iphone2android-0.2.0 → iphone2android-0.3.0}/tests/test_convert_layout.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iphone2android
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen, from an encrypted iPhone backup
|
|
5
5
|
Author: Konstantinos Georgiou
|
|
6
6
|
License-Expression: MIT
|
|
@@ -59,9 +59,14 @@ iphone2android convert --out android-import
|
|
|
59
59
|
iphone2android media message-attachments photo-edits
|
|
60
60
|
iphone2android appdata survey
|
|
61
61
|
|
|
62
|
+
iphone2android accounts # what to sign in to again
|
|
63
|
+
|
|
64
|
+
iphone2android launcher probe # which launcher, and which profile matches
|
|
62
65
|
iphone2android layout extracted/IconState.plist --mapping mapping.json > layout.json
|
|
63
66
|
iphone2android wallpaper extract
|
|
64
67
|
iphone2android build layout.json --state build.json --autofill
|
|
68
|
+
iphone2android check layout.json # every page and folder against the layout
|
|
69
|
+
iphone2android snapshot # the whole home screen as JSON
|
|
65
70
|
iphone2android wallpaper set wallpaper/<image>
|
|
66
71
|
```
|
|
67
72
|
|
|
@@ -83,9 +88,18 @@ iphone2android wallpaper set wallpaper/<image>
|
|
|
83
88
|
|
|
84
89
|
## How the home screen is rebuilt
|
|
85
90
|
|
|
86
|
-
Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does.
|
|
91
|
+
Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does. That only works with the right timings and gestures for each launcher, so they live in launcher profiles (`android/profiles/*.json`) as data, with the evidence for each. The ColorOS 16 profile was measured during a real migration on an OPPO Find X9 Pro. A few of the findings it holds:
|
|
92
|
+
|
|
93
|
+
- A swipe changes page at 250 ms and is read as a drag at 400 ms.
|
|
94
|
+
- A long press only opens the menu when the press, the wait and the release are sent in one adb shell.
|
|
95
|
+
- A drop from the app drawer always lands on the first page, so everything is made there and carried to its page by holding at the screen edge, 0.9 seconds per page.
|
|
96
|
+
- A slow hover merges an icon into a folder, while a 2 second drag onto an icon swaps them.
|
|
97
|
+
- A folder dropped onto a folder merges the two, and new folders can get the same automatic name, so folders are found by position.
|
|
98
|
+
- Icons cannot be dragged out of a folder, only removed inside it.
|
|
99
|
+
|
|
100
|
+
The builder follows these, reads the screen again after every step, saves its progress, checks every folder by opening it, and orders each page one swap at a time. For a launcher without a profile, the skill has a calibration procedure, and `iphone2android launcher save-profile` stores the result. Profiles for other launchers are very welcome.
|
|
87
101
|
|
|
88
|
-
Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen.
|
|
102
|
+
Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen. Widgets are listed for placing through the launcher's widget picker.
|
|
89
103
|
|
|
90
104
|
## Development
|
|
91
105
|
|
|
@@ -40,9 +40,14 @@ iphone2android convert --out android-import
|
|
|
40
40
|
iphone2android media message-attachments photo-edits
|
|
41
41
|
iphone2android appdata survey
|
|
42
42
|
|
|
43
|
+
iphone2android accounts # what to sign in to again
|
|
44
|
+
|
|
45
|
+
iphone2android launcher probe # which launcher, and which profile matches
|
|
43
46
|
iphone2android layout extracted/IconState.plist --mapping mapping.json > layout.json
|
|
44
47
|
iphone2android wallpaper extract
|
|
45
48
|
iphone2android build layout.json --state build.json --autofill
|
|
49
|
+
iphone2android check layout.json # every page and folder against the layout
|
|
50
|
+
iphone2android snapshot # the whole home screen as JSON
|
|
46
51
|
iphone2android wallpaper set wallpaper/<image>
|
|
47
52
|
```
|
|
48
53
|
|
|
@@ -64,9 +69,18 @@ iphone2android wallpaper set wallpaper/<image>
|
|
|
64
69
|
|
|
65
70
|
## How the home screen is rebuilt
|
|
66
71
|
|
|
67
|
-
Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does.
|
|
72
|
+
Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does. That only works with the right timings and gestures for each launcher, so they live in launcher profiles (`android/profiles/*.json`) as data, with the evidence for each. The ColorOS 16 profile was measured during a real migration on an OPPO Find X9 Pro. A few of the findings it holds:
|
|
73
|
+
|
|
74
|
+
- A swipe changes page at 250 ms and is read as a drag at 400 ms.
|
|
75
|
+
- A long press only opens the menu when the press, the wait and the release are sent in one adb shell.
|
|
76
|
+
- A drop from the app drawer always lands on the first page, so everything is made there and carried to its page by holding at the screen edge, 0.9 seconds per page.
|
|
77
|
+
- A slow hover merges an icon into a folder, while a 2 second drag onto an icon swaps them.
|
|
78
|
+
- A folder dropped onto a folder merges the two, and new folders can get the same automatic name, so folders are found by position.
|
|
79
|
+
- Icons cannot be dragged out of a folder, only removed inside it.
|
|
80
|
+
|
|
81
|
+
The builder follows these, reads the screen again after every step, saves its progress, checks every folder by opening it, and orders each page one swap at a time. For a launcher without a profile, the skill has a calibration procedure, and `iphone2android launcher save-profile` stores the result. Profiles for other launchers are very welcome.
|
|
68
82
|
|
|
69
|
-
Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen.
|
|
83
|
+
Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen. Widgets are listed for placing through the launcher's widget picker.
|
|
70
84
|
|
|
71
85
|
## Development
|
|
72
86
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "iphone2android"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen, from an encrypted iPhone backup"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -27,4 +27,4 @@ test = ["pytest>=8"]
|
|
|
27
27
|
where = ["src"]
|
|
28
28
|
|
|
29
29
|
[tool.setuptools.package-data]
|
|
30
|
-
iphone2android = ["skill/SKILL.md"]
|
|
30
|
+
iphone2android = ["skill/SKILL.md", "android/profiles/*.json"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The accounts the iPhone was signed in to (Google, Exchange, iCloud and others), from Accounts3.sqlite.
|
|
2
|
+
|
|
3
|
+
This is the reliable list of accounts to add on Android. Searching app data for email addresses
|
|
4
|
+
instead finds other people's addresses and typos. The output holds your addresses, so keep it local.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read(db: Path) -> list[dict]:
|
|
13
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
14
|
+
try:
|
|
15
|
+
rows = con.execute(
|
|
16
|
+
"""SELECT a.ZUSERNAME, a.ZACCOUNTDESCRIPTION, t.ZACCOUNTTYPEDESCRIPTION, t.ZIDENTIFIER
|
|
17
|
+
FROM ZACCOUNT a LEFT JOIN ZACCOUNTTYPE t ON a.ZACCOUNTTYPE = t.Z_PK"""
|
|
18
|
+
).fetchall()
|
|
19
|
+
finally:
|
|
20
|
+
con.close()
|
|
21
|
+
out = []
|
|
22
|
+
for user, desc, type_desc, ident in rows:
|
|
23
|
+
if not user:
|
|
24
|
+
continue
|
|
25
|
+
out.append({"username": user, "description": desc, "type": type_desc, "type_id": ident,
|
|
26
|
+
"google": bool(ident and "google" in ident.lower()) or str(user).lower().endswith(("@gmail.com", "@googlemail.com"))})
|
|
27
|
+
seen, unique = set(), []
|
|
28
|
+
for a in out:
|
|
29
|
+
key = (a["username"].lower(), a["type_id"])
|
|
30
|
+
if key not in seen:
|
|
31
|
+
seen.add(key)
|
|
32
|
+
unique.append(a)
|
|
33
|
+
return unique
|
|
@@ -10,6 +10,25 @@ import shutil
|
|
|
10
10
|
import subprocess
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def png_is_black(data: bytes) -> bool:
|
|
14
|
+
"""True when a PNG holds only black pixels (what a locked or sleeping screen captures as)."""
|
|
15
|
+
import struct
|
|
16
|
+
import zlib
|
|
17
|
+
|
|
18
|
+
pos, idat = 8, b""
|
|
19
|
+
while pos + 8 <= len(data):
|
|
20
|
+
n = struct.unpack(">I", data[pos:pos + 4])[0]
|
|
21
|
+
if data[pos + 4:pos + 8] == b"IDAT":
|
|
22
|
+
idat += data[pos + 8:pos + 8 + n]
|
|
23
|
+
pos += 12 + n
|
|
24
|
+
try:
|
|
25
|
+
raw = zlib.decompress(idat)
|
|
26
|
+
except zlib.error:
|
|
27
|
+
return False
|
|
28
|
+
# Black rows are filter bytes (0 to 4), zero colour bytes and 255 alpha bytes, nothing else.
|
|
29
|
+
return bool(raw) and set(raw) <= {0, 1, 2, 3, 4, 255}
|
|
30
|
+
|
|
31
|
+
|
|
13
32
|
class DeviceUnavailable(RuntimeError):
|
|
14
33
|
"""adb could not reach the phone. Raised instead of returning empty output, which would read as "nothing installed"."""
|
|
15
34
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Build a home screen from a layout file, the way the launcher actually behaves.
|
|
2
|
+
|
|
3
|
+
{"pages": [["Camera", {"folder": "Social", "apps": ["WhatsApp", "Telegram"]},
|
|
4
|
+
{"widget": ["Spotify"], "size": "medium"}]],
|
|
5
|
+
"dock": ["Phone", "Messages", "Chrome"]}
|
|
6
|
+
|
|
7
|
+
Each name is the app's name in the drawer. What the builder relies on (ColorOS 16, see the profile):
|
|
8
|
+
|
|
9
|
+
- A drop from the drawer lands on the FIRST page's first free cell, whatever page is showing. So
|
|
10
|
+
everything is made on the first page and then carried to its page with an edge hold, one hold
|
|
11
|
+
per page, and dropped on an EMPTY cell (a folder dropped on a folder merges them).
|
|
12
|
+
- Dropping a drawer result onto an icon makes a folder, and onto a folder adds to it. The new folder
|
|
13
|
+
is found by its position next to the seed icon, never by its name (auto-names collide).
|
|
14
|
+
- A folder's contents are checked by opening it with a fresh navigation. Extras are removed from
|
|
15
|
+
inside the folder. Missing apps are added by dropping from the drawer onto the folder.
|
|
16
|
+
- Order is fixed with Icon autofill off by a selection sort that makes one swap and then reads the
|
|
17
|
+
screen again. A swap that creates a folder stops the sort.
|
|
18
|
+
|
|
19
|
+
Progress is saved after every step, so an interrupted build continues where it stopped. Widgets are
|
|
20
|
+
listed for placing through the launcher's widget picker.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def entry_label(entry, prefix: str = "Folder:") -> str:
|
|
29
|
+
if isinstance(entry, dict) and "widget" in entry:
|
|
30
|
+
return f"Widget:{'/'.join(entry['widget'])}"
|
|
31
|
+
return f"{prefix}{entry['folder']}" if isinstance(entry, dict) else entry
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_widget(entry) -> bool:
|
|
35
|
+
return isinstance(entry, dict) and "widget" in entry
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Builder:
|
|
39
|
+
def __init__(self, launcher, state_file: Path | None = None, log=print, retries: int = 2):
|
|
40
|
+
self.l = launcher
|
|
41
|
+
self.state_file = state_file
|
|
42
|
+
self.log = log
|
|
43
|
+
self.retries = retries
|
|
44
|
+
self.done: list[str] = []
|
|
45
|
+
self.todo: list[dict] = []
|
|
46
|
+
self.problems: list[str] = []
|
|
47
|
+
if state_file and state_file.exists():
|
|
48
|
+
self.done = json.loads(state_file.read_text()).get("done", [])
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def prefix(self) -> str:
|
|
52
|
+
return self.l.p["labels"]["folder_prefix"] if hasattr(self.l, "p") else "Folder:"
|
|
53
|
+
|
|
54
|
+
def _mark(self, key: str) -> None:
|
|
55
|
+
self.done.append(key)
|
|
56
|
+
if self.state_file:
|
|
57
|
+
self.state_file.write_text(json.dumps({"done": self.done}))
|
|
58
|
+
|
|
59
|
+
def _find(self, page: int, label: str):
|
|
60
|
+
hits = [i for i in self.l.page(page) if i.label == label]
|
|
61
|
+
return hits[0] if hits else None
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------- placing
|
|
64
|
+
|
|
65
|
+
def place_app(self, name: str) -> object | None:
|
|
66
|
+
"""Drop an app from the drawer onto the first page. Returns where it landed."""
|
|
67
|
+
before = {(i.label, i.cell) for i in self.l.page(0)}
|
|
68
|
+
hit = self.l.drawer_find(name, want=name)
|
|
69
|
+
if not hit:
|
|
70
|
+
self.problems.append(f"not in the drawer: {name}")
|
|
71
|
+
return None
|
|
72
|
+
self.l.drop_from_drawer(hit)
|
|
73
|
+
new = [i for i in self.l.page(0) if (i.label, i.cell) not in before and i.label == hit.label]
|
|
74
|
+
if not new:
|
|
75
|
+
self.problems.append(f"{name}: no new icon on the first page after the drop (page full?)")
|
|
76
|
+
return None
|
|
77
|
+
return new[0]
|
|
78
|
+
|
|
79
|
+
def carry(self, item, to_page: int) -> bool:
|
|
80
|
+
"""Move an icon or folder from the first page to another page, onto an empty cell."""
|
|
81
|
+
if to_page == 0:
|
|
82
|
+
return True
|
|
83
|
+
target = self.l.page(to_page) if to_page < len(self.l.pages()) else []
|
|
84
|
+
free = self.l.free_cells(target)
|
|
85
|
+
if target and not free:
|
|
86
|
+
self.problems.append(f"page {to_page + 1} is full, cannot carry {item.label}")
|
|
87
|
+
return False
|
|
88
|
+
for attempt in range(self.retries + 1):
|
|
89
|
+
src = self._find(0, item.label)
|
|
90
|
+
if not src:
|
|
91
|
+
break
|
|
92
|
+
self.l.move_to_page(src, to_page, free[0] if free else None)
|
|
93
|
+
if self._find(to_page, item.label):
|
|
94
|
+
return True
|
|
95
|
+
self.log(f" carry {item.label} to page {to_page + 1}: retry {attempt + 1}")
|
|
96
|
+
self.problems.append(f"could not carry {item.label} to page {to_page + 1}")
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def build_folder(self, name: str, apps: list[str]) -> object | None:
|
|
100
|
+
"""Make a folder on the first page from apps in the drawer, name it, and check its contents."""
|
|
101
|
+
label = f"{self.prefix}{name}"
|
|
102
|
+
existing = self._find(0, label)
|
|
103
|
+
if not existing:
|
|
104
|
+
seed = self.place_app(apps[0])
|
|
105
|
+
if not seed or len(apps) == 1:
|
|
106
|
+
return seed
|
|
107
|
+
before = {(i.label, i.cell) for i in self.l.page(0) if i.kind == "folder"}
|
|
108
|
+
hit = self.l.drawer_find(apps[1], want=apps[1])
|
|
109
|
+
if not hit:
|
|
110
|
+
self.problems.append(f"not in the drawer: {apps[1]}")
|
|
111
|
+
return seed
|
|
112
|
+
self.l.drop_from_drawer(hit, onto=(seed.x, seed.y))
|
|
113
|
+
made = [i for i in self.l.page(0) if i.kind == "folder" and (i.label, i.cell) not in before]
|
|
114
|
+
if not made:
|
|
115
|
+
self.problems.append(f"folder {name}: dropping {apps[1]} onto {apps[0]} made no folder")
|
|
116
|
+
return None
|
|
117
|
+
folder = min(made, key=lambda f: abs(f.x - seed.x) + abs(f.y - seed.y))
|
|
118
|
+
self.l.tap(folder.x, folder.y)
|
|
119
|
+
self.l.rename_open_folder(name)
|
|
120
|
+
self.l.home()
|
|
121
|
+
self.fill_folder(0, name, apps)
|
|
122
|
+
return self._find(0, label)
|
|
123
|
+
|
|
124
|
+
def fill_folder(self, page: int, name: str, apps: list[str]) -> None:
|
|
125
|
+
label = f"{self.prefix}{name}"
|
|
126
|
+
for attempt in range(self.retries + 1):
|
|
127
|
+
inside = self.l.open_folder(page, label)
|
|
128
|
+
if inside is None:
|
|
129
|
+
self.problems.append(f"folder {name} is not on page {page + 1}")
|
|
130
|
+
return
|
|
131
|
+
missing = [a for a in apps if not any(a.lower() == x.lower() for x in inside)]
|
|
132
|
+
extra = [x for x in inside if not any(a.lower() == x.lower() for a in apps)]
|
|
133
|
+
for x in extra:
|
|
134
|
+
self.log(f" {name}: removing {x}, which does not belong")
|
|
135
|
+
self.l.remove_from_folder(page, label, x)
|
|
136
|
+
if not missing:
|
|
137
|
+
return
|
|
138
|
+
for a in missing:
|
|
139
|
+
folder = self._find(page, label)
|
|
140
|
+
hit = self.l.drawer_find(a, want=a)
|
|
141
|
+
if not hit or not folder:
|
|
142
|
+
continue
|
|
143
|
+
if page != 0:
|
|
144
|
+
self.problems.append(f"folder {name}: add {a} on the first page first")
|
|
145
|
+
continue
|
|
146
|
+
self.l.drop_from_drawer(hit, onto=(folder.x, folder.y))
|
|
147
|
+
inside = self.l.open_folder(page, label) or []
|
|
148
|
+
still = [a for a in apps if not any(a.lower() == x.lower() for x in inside)]
|
|
149
|
+
if still:
|
|
150
|
+
self.problems.append(f"folder {name}: still missing {', '.join(still)}")
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------- the whole layout
|
|
153
|
+
|
|
154
|
+
def build(self, layout: dict) -> None:
|
|
155
|
+
# Build back to front, so carrying an item never passes a page that is already finished.
|
|
156
|
+
for page in reversed(range(len(layout.get("pages", [])))):
|
|
157
|
+
for e in layout["pages"][page]:
|
|
158
|
+
key = f"p{page}:{entry_label(e, self.prefix)}"
|
|
159
|
+
if key in self.done:
|
|
160
|
+
continue
|
|
161
|
+
if is_widget(e):
|
|
162
|
+
self.todo.append({"page": page + 1, "widget": e["widget"], "size": e.get("size")})
|
|
163
|
+
self.log(f" widget {'/'.join(e['widget'])} ({e.get('size')}): place through the widget picker")
|
|
164
|
+
self._mark(key)
|
|
165
|
+
continue
|
|
166
|
+
self.log(f"page {page + 1}: {entry_label(e, self.prefix)}")
|
|
167
|
+
item = self.build_folder(e["folder"], e["apps"]) if isinstance(e, dict) else self.place_app(e)
|
|
168
|
+
if item and self.carry(item, page):
|
|
169
|
+
self._mark(key)
|
|
170
|
+
|
|
171
|
+
def order(self, page: int, wanted: list[str]) -> bool:
|
|
172
|
+
"""Selection sort with one swap per reading. Returns False when it had to stop."""
|
|
173
|
+
for i, label in enumerate(wanted):
|
|
174
|
+
items = [x for x in self.l.page(page) if x.kind != "widget"]
|
|
175
|
+
names = [x.label for x in items]
|
|
176
|
+
if i >= len(items) or names[i] == label:
|
|
177
|
+
continue
|
|
178
|
+
if label not in names:
|
|
179
|
+
self.problems.append(f"{label} is not on page {page + 1}")
|
|
180
|
+
continue
|
|
181
|
+
folders = {x.label for x in items if x.kind == "folder"}
|
|
182
|
+
self.l.swap(items[names.index(label)], items[i])
|
|
183
|
+
after = {x.label for x in self.l.page(page) if x.kind == "folder"}
|
|
184
|
+
if after - folders:
|
|
185
|
+
self.problems.append(f"page {page + 1}: a swap created a folder {sorted(after - folders)}, stopped")
|
|
186
|
+
return False
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
def order_all(self, layout: dict) -> bool:
|
|
190
|
+
ok = True
|
|
191
|
+
for page, entries in enumerate(layout.get("pages", [])):
|
|
192
|
+
ok = self.order(page, [entry_label(e, self.prefix) for e in entries if not is_widget(e)]) and ok
|
|
193
|
+
return ok
|
|
194
|
+
|
|
195
|
+
def check(self, layout: dict) -> list[str]:
|
|
196
|
+
"""Compare every page and folder with the layout. Returns the differences (empty when it matches)."""
|
|
197
|
+
diffs = []
|
|
198
|
+
pages = self.l.pages()
|
|
199
|
+
for p, entries in enumerate(layout.get("pages", [])):
|
|
200
|
+
have = [i.label for i in pages[p] if i.kind != "widget"] if p < len(pages) else []
|
|
201
|
+
want = [entry_label(e, self.prefix) for e in entries if not is_widget(e)]
|
|
202
|
+
if have != want:
|
|
203
|
+
diffs.append(f"page {p + 1}: have {have}, want {want}")
|
|
204
|
+
for e in entries:
|
|
205
|
+
if isinstance(e, dict) and "folder" in e:
|
|
206
|
+
inside = self.l.open_folder(p, entry_label(e, self.prefix)) or []
|
|
207
|
+
if sorted(x.lower() for x in inside) != sorted(a.lower() for a in e["apps"]):
|
|
208
|
+
diffs.append(f"folder {e['folder']}: have {inside}, want {e['apps']}")
|
|
209
|
+
return diffs
|