mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da35a05b84 | ||
|
|
a90eeecda9 | ||
|
|
c98ed0d83b | ||
|
|
c46b2c1082 | ||
|
|
fde9c2d271 | ||
|
|
4fd14eb745 | ||
|
|
b3c03a7162 | ||
|
|
8749d6c2c6 | ||
|
|
4ee2aed29b | ||
|
|
753056418c | ||
|
|
2757977fd6 | ||
|
|
2f14c1688f | ||
|
|
7dbf818d83 | ||
|
|
c3ec82392a | ||
|
|
ce4656e46a | ||
|
|
557eeb5f1d | ||
|
|
b47e6c91a0 | ||
|
|
5c1da9d7a5 | ||
|
|
0135c29a4f | ||
|
|
0b81b76cb7 | ||
|
|
7470bd89f3 | ||
|
|
e6cb52339d | ||
|
|
912acc760a |
No files matched your search
Generated
+35
-8
@@ -3389,6 +3389,31 @@ dependencies = [
|
|||||||
"system-deps 9.0.0",
|
"system-deps 9.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pangocairo"
|
||||||
|
version = "0.22.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bd199a0fef965b3ebda91a929a1ab5e7729538882c68e8de38e1904d2e094757"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-rs",
|
||||||
|
"glib",
|
||||||
|
"pango",
|
||||||
|
"pangocairo-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pangocairo-sys"
|
||||||
|
version = "0.22.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a649a616d67ae68539bc85f00fb45d36fba742ed20fc20e6712a15d8ee52bd3f"
|
||||||
|
dependencies = [
|
||||||
|
"cairo-sys-rs",
|
||||||
|
"glib-sys",
|
||||||
|
"libc",
|
||||||
|
"pango-sys",
|
||||||
|
"system-deps 9.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -3449,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "2.2.1"
|
version = "2.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3464,7 +3489,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "2.2.1"
|
version = "2.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
@@ -3488,7 +3513,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "2.2.1"
|
version = "2.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"fuser",
|
"fuser",
|
||||||
@@ -3508,7 +3533,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "2.2.1"
|
version = "2.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3517,7 +3542,9 @@ dependencies = [
|
|||||||
"kamadak-exif",
|
"kamadak-exif",
|
||||||
"ksni",
|
"ksni",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
|
"pangocairo",
|
||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3746,9 +3773,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.6.6"
|
version = "0.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34d587f1ef2ffa9183f48f0cca407c4b641b636be516dde8b13e082de5c83757"
|
checksum = "d0f6996724e51e99f03d2843828de9477afc39baa764403cb69490cabc1c4d93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3773,9 +3800,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.6.6"
|
version = "0.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7decd4f7ec188815192a1d2a16a83f2d52944141c5b8e648184ef17061c9ecce"
|
checksum = "91cc25654133bd5714407a1df0996eaf214a6e8e0bb95d8139bc42e7cee31c34"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.2.1"
|
version = "2.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.6.6" }
|
proton-sdk = { version = "0.7.0" }
|
||||||
proton-drive-rs = { version = "0.6.6" }
|
proton-drive-rs = { version = "0.7.0" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ pdfs-app # the desktop app
|
|||||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||||
|
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo, as cards or on a world map. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0), and the map is drawn offline from [Natural Earth](https://www.naturalearthdata.com/) land outlines and country borders (public domain). Each town is marked with one of its photos, and zoomed in, every photo sits where it was taken. A street map from [OpenFreeMap](https://openfreemap.org/) (© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors) can be turned on under Appearance; it is off by default because the tile server learns which parts of the world you look at. Install `iso-codes` for country names in your language.
|
||||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||||
|
|||||||
@@ -2074,6 +2074,7 @@ fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> {
|
|||||||
kind: None,
|
kind: None,
|
||||||
range: None,
|
range: None,
|
||||||
favorites,
|
favorites,
|
||||||
|
not_in_album: false,
|
||||||
})? {
|
})? {
|
||||||
CtlResponse::Photos {
|
CtlResponse::Photos {
|
||||||
available: false, ..
|
available: false, ..
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,250 @@
|
|||||||
|
AD Andorra
|
||||||
|
AE United Arab Emirates
|
||||||
|
AF Afghanistan
|
||||||
|
AG Antigua and Barbuda
|
||||||
|
AI Anguilla
|
||||||
|
AL Albania
|
||||||
|
AM Armenia
|
||||||
|
AO Angola
|
||||||
|
AQ Antarctica
|
||||||
|
AR Argentina
|
||||||
|
AS American Samoa
|
||||||
|
AT Austria
|
||||||
|
AU Australia
|
||||||
|
AW Aruba
|
||||||
|
AX Åland Islands
|
||||||
|
AZ Azerbaijan
|
||||||
|
BA Bosnia and Herzegovina
|
||||||
|
BB Barbados
|
||||||
|
BD Bangladesh
|
||||||
|
BE Belgium
|
||||||
|
BF Burkina Faso
|
||||||
|
BG Bulgaria
|
||||||
|
BH Bahrain
|
||||||
|
BI Burundi
|
||||||
|
BJ Benin
|
||||||
|
BL Saint Barthélemy
|
||||||
|
BM Bermuda
|
||||||
|
BN Brunei Darussalam
|
||||||
|
BO Bolivia, Plurinational State of
|
||||||
|
BQ Bonaire, Sint Eustatius and Saba
|
||||||
|
BR Brazil
|
||||||
|
BS Bahamas
|
||||||
|
BT Bhutan
|
||||||
|
BV Bouvet Island
|
||||||
|
BW Botswana
|
||||||
|
BY Belarus
|
||||||
|
BZ Belize
|
||||||
|
CA Canada
|
||||||
|
CC Cocos (Keeling) Islands
|
||||||
|
CD Congo, The Democratic Republic of the
|
||||||
|
CF Central African Republic
|
||||||
|
CG Congo
|
||||||
|
CH Switzerland
|
||||||
|
CI Côte d'Ivoire
|
||||||
|
CK Cook Islands
|
||||||
|
CL Chile
|
||||||
|
CM Cameroon
|
||||||
|
CN China
|
||||||
|
CO Colombia
|
||||||
|
CR Costa Rica
|
||||||
|
CU Cuba
|
||||||
|
CV Cabo Verde
|
||||||
|
CW Curaçao
|
||||||
|
CX Christmas Island
|
||||||
|
CY Cyprus
|
||||||
|
CZ Czechia
|
||||||
|
DE Germany
|
||||||
|
DJ Djibouti
|
||||||
|
DK Denmark
|
||||||
|
DM Dominica
|
||||||
|
DO Dominican Republic
|
||||||
|
DZ Algeria
|
||||||
|
EC Ecuador
|
||||||
|
EE Estonia
|
||||||
|
EG Egypt
|
||||||
|
EH Western Sahara
|
||||||
|
ER Eritrea
|
||||||
|
ES Spain
|
||||||
|
ET Ethiopia
|
||||||
|
FI Finland
|
||||||
|
FJ Fiji
|
||||||
|
FK Falkland Islands (Malvinas)
|
||||||
|
FM Micronesia, Federated States of
|
||||||
|
FO Faroe Islands
|
||||||
|
FR France
|
||||||
|
GA Gabon
|
||||||
|
GB United Kingdom
|
||||||
|
GD Grenada
|
||||||
|
GE Georgia
|
||||||
|
GF French Guiana
|
||||||
|
GG Guernsey
|
||||||
|
GH Ghana
|
||||||
|
GI Gibraltar
|
||||||
|
GL Greenland
|
||||||
|
GM Gambia
|
||||||
|
GN Guinea
|
||||||
|
GP Guadeloupe
|
||||||
|
GQ Equatorial Guinea
|
||||||
|
GR Greece
|
||||||
|
GS South Georgia and the South Sandwich Islands
|
||||||
|
GT Guatemala
|
||||||
|
GU Guam
|
||||||
|
GW Guinea-Bissau
|
||||||
|
GY Guyana
|
||||||
|
HK Hong Kong
|
||||||
|
HM Heard Island and McDonald Islands
|
||||||
|
HN Honduras
|
||||||
|
HR Croatia
|
||||||
|
HT Haiti
|
||||||
|
HU Hungary
|
||||||
|
ID Indonesia
|
||||||
|
IE Ireland
|
||||||
|
IL Israel
|
||||||
|
IM Isle of Man
|
||||||
|
IN India
|
||||||
|
IO British Indian Ocean Territory
|
||||||
|
IQ Iraq
|
||||||
|
IR Iran, Islamic Republic of
|
||||||
|
IS Iceland
|
||||||
|
IT Italy
|
||||||
|
JE Jersey
|
||||||
|
JM Jamaica
|
||||||
|
JO Jordan
|
||||||
|
JP Japan
|
||||||
|
KE Kenya
|
||||||
|
KG Kyrgyzstan
|
||||||
|
KH Cambodia
|
||||||
|
KI Kiribati
|
||||||
|
KM Comoros
|
||||||
|
KN Saint Kitts and Nevis
|
||||||
|
KP Korea, Democratic People's Republic of
|
||||||
|
KR Korea, Republic of
|
||||||
|
KW Kuwait
|
||||||
|
KY Cayman Islands
|
||||||
|
KZ Kazakhstan
|
||||||
|
LA Lao People's Democratic Republic
|
||||||
|
LB Lebanon
|
||||||
|
LC Saint Lucia
|
||||||
|
LI Liechtenstein
|
||||||
|
LK Sri Lanka
|
||||||
|
LR Liberia
|
||||||
|
LS Lesotho
|
||||||
|
LT Lithuania
|
||||||
|
LU Luxembourg
|
||||||
|
LV Latvia
|
||||||
|
LY Libya
|
||||||
|
MA Morocco
|
||||||
|
MC Monaco
|
||||||
|
MD Moldova, Republic of
|
||||||
|
ME Montenegro
|
||||||
|
MF Saint Martin (French part)
|
||||||
|
MG Madagascar
|
||||||
|
MH Marshall Islands
|
||||||
|
MK North Macedonia
|
||||||
|
ML Mali
|
||||||
|
MM Myanmar
|
||||||
|
MN Mongolia
|
||||||
|
MO Macao
|
||||||
|
MP Northern Mariana Islands
|
||||||
|
MQ Martinique
|
||||||
|
MR Mauritania
|
||||||
|
MS Montserrat
|
||||||
|
MT Malta
|
||||||
|
MU Mauritius
|
||||||
|
MV Maldives
|
||||||
|
MW Malawi
|
||||||
|
MX Mexico
|
||||||
|
MY Malaysia
|
||||||
|
MZ Mozambique
|
||||||
|
NA Namibia
|
||||||
|
NC New Caledonia
|
||||||
|
NE Niger
|
||||||
|
NF Norfolk Island
|
||||||
|
NG Nigeria
|
||||||
|
NI Nicaragua
|
||||||
|
NL Netherlands
|
||||||
|
NO Norway
|
||||||
|
NP Nepal
|
||||||
|
NR Nauru
|
||||||
|
NU Niue
|
||||||
|
NZ New Zealand
|
||||||
|
OM Oman
|
||||||
|
PA Panama
|
||||||
|
PE Peru
|
||||||
|
PF French Polynesia
|
||||||
|
PG Papua New Guinea
|
||||||
|
PH Philippines
|
||||||
|
PK Pakistan
|
||||||
|
PL Poland
|
||||||
|
PM Saint Pierre and Miquelon
|
||||||
|
PN Pitcairn
|
||||||
|
PR Puerto Rico
|
||||||
|
PS Palestine, State of
|
||||||
|
PT Portugal
|
||||||
|
PW Palau
|
||||||
|
PY Paraguay
|
||||||
|
QA Qatar
|
||||||
|
RE Réunion
|
||||||
|
RO Romania
|
||||||
|
RS Serbia
|
||||||
|
RU Russian Federation
|
||||||
|
RW Rwanda
|
||||||
|
SA Saudi Arabia
|
||||||
|
SB Solomon Islands
|
||||||
|
SC Seychelles
|
||||||
|
SD Sudan
|
||||||
|
SE Sweden
|
||||||
|
SG Singapore
|
||||||
|
SH Saint Helena, Ascension and Tristan da Cunha
|
||||||
|
SI Slovenia
|
||||||
|
SJ Svalbard and Jan Mayen
|
||||||
|
SK Slovakia
|
||||||
|
SL Sierra Leone
|
||||||
|
SM San Marino
|
||||||
|
SN Senegal
|
||||||
|
SO Somalia
|
||||||
|
SR Suriname
|
||||||
|
SS South Sudan
|
||||||
|
ST Sao Tome and Principe
|
||||||
|
SV El Salvador
|
||||||
|
SX Sint Maarten (Dutch part)
|
||||||
|
SY Syrian Arab Republic
|
||||||
|
SZ Eswatini
|
||||||
|
TC Turks and Caicos Islands
|
||||||
|
TD Chad
|
||||||
|
TF French Southern Territories
|
||||||
|
TG Togo
|
||||||
|
TH Thailand
|
||||||
|
TJ Tajikistan
|
||||||
|
TK Tokelau
|
||||||
|
TL Timor-Leste
|
||||||
|
TM Turkmenistan
|
||||||
|
TN Tunisia
|
||||||
|
TO Tonga
|
||||||
|
TR Türkiye
|
||||||
|
TT Trinidad and Tobago
|
||||||
|
TV Tuvalu
|
||||||
|
TW Taiwan, Province of China
|
||||||
|
TZ Tanzania, United Republic of
|
||||||
|
UA Ukraine
|
||||||
|
UG Uganda
|
||||||
|
UM United States Minor Outlying Islands
|
||||||
|
US United States
|
||||||
|
UY Uruguay
|
||||||
|
UZ Uzbekistan
|
||||||
|
VA Holy See (Vatican City State)
|
||||||
|
VC Saint Vincent and the Grenadines
|
||||||
|
VE Venezuela, Bolivarian Republic of
|
||||||
|
VG Virgin Islands, British
|
||||||
|
VI Virgin Islands, U.S.
|
||||||
|
VN Viet Nam
|
||||||
|
VU Vanuatu
|
||||||
|
WF Wallis and Futuna
|
||||||
|
WS Samoa
|
||||||
|
XK Kosovo
|
||||||
|
YE Yemen
|
||||||
|
YT Mayotte
|
||||||
|
ZA South Africa
|
||||||
|
ZM Zambia
|
||||||
|
ZW Zimbabwe
|
||||||
|
@@ -128,11 +128,12 @@ pub struct AppConfig {
|
|||||||
/// field.
|
/// field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prompt: Option<PromptConfig>,
|
pub prompt: Option<PromptConfig>,
|
||||||
/// Paint the desktop app in Proton purple instead of the system accent
|
/// Paint the desktop app in Proton's colours instead of the system theme
|
||||||
/// colour. `None` means "follow the system". Defaulted for configs
|
/// and accent colour. `None` means "follow the system". Defaulted for
|
||||||
/// predating the field.
|
/// configs predating the field; also read from `proton_accent`, its name
|
||||||
#[serde(default)]
|
/// while it only changed the accent.
|
||||||
pub proton_accent: Option<bool>,
|
#[serde(default, alias = "proton_accent")]
|
||||||
|
pub proton_theme: Option<bool>,
|
||||||
/// How the desktop app's My Files page lays out and orders a folder.
|
/// How the desktop app's My Files page lays out and orders a folder.
|
||||||
/// Defaulted for configs predating the field.
|
/// Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -147,6 +148,12 @@ pub struct AppConfig {
|
|||||||
/// Defaulted for configs predating the field.
|
/// Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
|
/// Draw the Places map from OpenFreeMap's street map instead of the
|
||||||
|
/// built-in outline. Off by default: the tiles come from a third party,
|
||||||
|
/// which then learns which parts of the world are looked at. Defaulted for
|
||||||
|
/// configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub online_map: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The My Files page's view choices, remembered between runs.
|
/// The My Files page's view choices, remembered between runs.
|
||||||
@@ -216,10 +223,11 @@ impl Default for AppConfig {
|
|||||||
conflict_sweep: None,
|
conflict_sweep: None,
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
proton_accent: None,
|
proton_theme: None,
|
||||||
files_view: FilesView::default(),
|
files_view: FilesView::default(),
|
||||||
tray_hidden: false,
|
tray_hidden: false,
|
||||||
language: None,
|
language: None,
|
||||||
|
online_map: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,7 +574,7 @@ mod tests {
|
|||||||
conflict_sweep: Some(SweepMode::Enforce),
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
proton_accent: Some(true),
|
proton_theme: Some(true),
|
||||||
files_view: FilesView {
|
files_view: FilesView {
|
||||||
list: true,
|
list: true,
|
||||||
sort: FileSort::Modified,
|
sort: FileSort::Modified,
|
||||||
@@ -575,13 +583,15 @@ mod tests {
|
|||||||
},
|
},
|
||||||
tray_hidden: true,
|
tray_hidden: true,
|
||||||
language: Some("de".to_string()),
|
language: Some("de".to_string()),
|
||||||
|
online_map: true,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||||
assert_eq!(decoded.proton_accent, Some(true));
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
assert!(decoded.tray_hidden);
|
assert!(decoded.tray_hidden);
|
||||||
|
assert!(decoded.online_map);
|
||||||
assert_eq!(decoded.language.as_deref(), Some("de"));
|
assert_eq!(decoded.language.as_deref(), Some("de"));
|
||||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||||
assert_eq!(decoded.download_limit, None);
|
assert_eq!(decoded.download_limit, None);
|
||||||
@@ -598,6 +608,14 @@ mod tests {
|
|||||||
assert!(partial.folders_first);
|
assert!(partial.folders_first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_old_proton_accent_setting_turns_on_the_proton_theme() {
|
||||||
|
let decoded: AppConfig =
|
||||||
|
serde_json::from_str(r#"{"app_version":"a","user_agent":"b","proton_accent":true}"#)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_app_config() {
|
fn test_default_app_config() {
|
||||||
let default_config = AppConfig::default();
|
let default_config = AppConfig::default();
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ pub enum Request {
|
|||||||
/// get the whole timeline, as before.
|
/// get the whole timeline, as before.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
favorites: bool,
|
favorites: bool,
|
||||||
|
/// Restrict the page to photos no album of ours holds — what is left to
|
||||||
|
/// file. Older front-ends omit it and get the whole timeline, as before.
|
||||||
|
#[serde(default)]
|
||||||
|
not_in_album: bool,
|
||||||
},
|
},
|
||||||
/// Every photo of one photo's group — the JPEG and the RAW of one shot, a
|
/// Every photo of one photo's group — the JPEG and the RAW of one shot, a
|
||||||
/// live photo and its clip. Replies with [`Response::Photos`], holding one
|
/// live photo and its clip. Replies with [`Response::Photos`], holding one
|
||||||
@@ -80,6 +84,34 @@ pub enum Request {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
kind: Option<PhotoKind>,
|
kind: Option<PhotoKind>,
|
||||||
},
|
},
|
||||||
|
/// Photos taken on today's date in earlier years, newest first, at most
|
||||||
|
/// `limit` of them — the "On this day" strip above the timeline. Replies
|
||||||
|
/// with [`Response::Photos`]; a front-end groups them by year itself.
|
||||||
|
PhotosOnThisDay { limit: usize },
|
||||||
|
/// Sets of photos that are byte-identical copies of each other, going by
|
||||||
|
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
|
||||||
|
PhotoDuplicates,
|
||||||
|
/// Sets of shots that look alike going by their thumbnails: resized or
|
||||||
|
/// re-encoded copies, and bursts of one moment. Starts hashing the
|
||||||
|
/// thumbnails not hashed yet in the background and replies at once with
|
||||||
|
/// the sets found so far; a front-end asks again until the reply's
|
||||||
|
/// `hashing` is `None`. Replies with [`Response::PhotoSimilar`].
|
||||||
|
PhotoSimilar,
|
||||||
|
/// The towns the photos were taken in, the one with the most photos first.
|
||||||
|
/// Only photos whose uploader recorded a location count. Replies with
|
||||||
|
/// [`Response::Places`].
|
||||||
|
PhotoPlaces,
|
||||||
|
/// Fetch a page of the photos taken in one town, newest first. `id` is the
|
||||||
|
/// town's id from [`Request::PhotoPlaces`]. Replies with
|
||||||
|
/// [`Response::Photos`], like an album page.
|
||||||
|
PlacePhotos {
|
||||||
|
id: u32,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
/// Every photo taken in one town with where exactly it was taken, for the
|
||||||
|
/// map's close-up. Replies with [`Response::PhotoLocations`].
|
||||||
|
PlaceLocations { id: u32 },
|
||||||
/// List the account's photo albums, newest activity first, including the
|
/// List the account's photo albums, newest activity first, including the
|
||||||
/// albums other people share with us. Metadata only — an album's cover
|
/// albums other people share with us. Metadata only — an album's cover
|
||||||
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
||||||
@@ -126,8 +158,14 @@ pub enum Request {
|
|||||||
///
|
///
|
||||||
/// A photo that belongs to a group takes the whole group with it: trashing
|
/// A photo that belongs to a group takes the whole group with it: trashing
|
||||||
/// the JPEG of a shot and leaving its RAW behind is not what anyone means by
|
/// the JPEG of a shot and leaving its RAW behind is not what anyone means by
|
||||||
/// deleting that photo.
|
/// deleting that photo. With `files_only` set, exactly the listed files
|
||||||
TrashNodes { uids: Vec<String> },
|
/// go and their groups stay: the duplicate finder removes one copy of a
|
||||||
|
/// shot, not the shot. Older front-ends omit it and trash whole groups.
|
||||||
|
TrashNodes {
|
||||||
|
uids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
files_only: bool,
|
||||||
|
},
|
||||||
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
|
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
|
||||||
/// timeline. The modification time is the cache validity tag, so replacing
|
/// timeline. The modification time is the cache validity tag, so replacing
|
||||||
/// an image can never reuse its previous revision's thumbnail.
|
/// an image can never reuse its previous revision's thumbnail.
|
||||||
@@ -429,10 +467,12 @@ pub enum Request {
|
|||||||
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
||||||
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
||||||
RetryPendingOp { id: Option<i64> },
|
RetryPendingOp { id: Option<i64> },
|
||||||
/// List the `(sync-conflict …)` copies under My Files. Replies with
|
/// List the `(sync-conflict …)` copies under My Files and in synced
|
||||||
|
/// folders. Replies with
|
||||||
/// [`Response::Conflicts`].
|
/// [`Response::Conflicts`].
|
||||||
ListConflicts,
|
ListConflicts,
|
||||||
/// Resolve one conflict copy (`path` mountpoint-relative or absolute).
|
/// Resolve one conflict copy (`path` mountpoint-relative, or absolute for a
|
||||||
|
/// copy in a synced folder).
|
||||||
/// Everything removed goes to the Proton trash. Replies with
|
/// Everything removed goes to the Proton trash. Replies with
|
||||||
/// [`Response::Ok`].
|
/// [`Response::Ok`].
|
||||||
ResolveConflict { path: String, keep: ConflictKeep },
|
ResolveConflict { path: String, keep: ConflictKeep },
|
||||||
@@ -701,9 +741,10 @@ pub struct DeviceInfo {
|
|||||||
/// [`Response::Conflicts`]).
|
/// [`Response::Conflicts`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct ConflictInfo {
|
pub struct ConflictInfo {
|
||||||
/// Mountpoint-relative path of the copy.
|
/// Mountpoint-relative path of the copy, or its absolute local path when it
|
||||||
|
/// sits in a synced folder.
|
||||||
pub path: String,
|
pub path: String,
|
||||||
/// Mountpoint-relative path of the file it is a copy of.
|
/// Path of the file it is a copy of, in the same form as `path`.
|
||||||
pub original_path: String,
|
pub original_path: String,
|
||||||
/// False when the original is gone and only the copy is left.
|
/// False when the original is gone and only the copy is left.
|
||||||
pub original_exists: bool,
|
pub original_exists: bool,
|
||||||
@@ -1415,6 +1456,45 @@ pub struct AlbumInfo {
|
|||||||
pub shared: bool,
|
pub shared: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One town in a [`Response::Places`] listing.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct PlaceInfo {
|
||||||
|
/// The town's GeoNames id — what [`Request::PlacePhotos`] takes.
|
||||||
|
pub id: u32,
|
||||||
|
/// The town's name, as the town itself spells it.
|
||||||
|
pub name: String,
|
||||||
|
/// ISO 3166-1 alpha-2 code of the town's country.
|
||||||
|
pub country: String,
|
||||||
|
/// Where the town is, in decimal degrees, for the map. Zero from a daemon
|
||||||
|
/// older than the map.
|
||||||
|
#[serde(default)]
|
||||||
|
pub latitude: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub longitude: f64,
|
||||||
|
/// How many photos were taken there, counting a RAW+JPEG shot once.
|
||||||
|
pub photo_count: usize,
|
||||||
|
/// The newest photo taken there, shown as the place's cover.
|
||||||
|
pub cover: PhotoItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo and where it was taken, in decimal degrees. Part of
|
||||||
|
/// [`Response::PhotoLocations`].
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct PhotoLocation {
|
||||||
|
pub photo: PhotoItem,
|
||||||
|
pub latitude: f64,
|
||||||
|
pub longitude: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many of the timeline's photos a running refresh has read the metadata
|
||||||
|
/// (and so the location) of, out of how many. Part of [`Response::Places`],
|
||||||
|
/// and of [`Response::PhotoSimilar`] for how many shots have been hashed.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct MappingProgress {
|
||||||
|
pub done: usize,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// One photo in a [`Request::PhotosTimeline`] page.
|
/// One photo in a [`Request::PhotosTimeline`] page.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct PhotoItem {
|
pub struct PhotoItem {
|
||||||
@@ -1773,6 +1853,31 @@ pub enum Response {
|
|||||||
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||||
/// newest first.
|
/// newest first.
|
||||||
PhotoMonths { months: Vec<PhotoMonth> },
|
PhotoMonths { months: Vec<PhotoMonth> },
|
||||||
|
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
|
||||||
|
/// first. Each set lists the copy worth keeping first.
|
||||||
|
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
|
||||||
|
/// The sets of alike shots (reply to [`Request::PhotoSimilar`]), newest
|
||||||
|
/// first, each in capture order; and how far hashing has got, `None`
|
||||||
|
/// once no pass is running. `uncompared` counts the shots left out
|
||||||
|
/// because no thumbnail could be read for them.
|
||||||
|
PhotoSimilar {
|
||||||
|
sets: Vec<Vec<PhotoItem>>,
|
||||||
|
#[serde(default)]
|
||||||
|
hashing: Option<MappingProgress>,
|
||||||
|
#[serde(default)]
|
||||||
|
uncompared: usize,
|
||||||
|
},
|
||||||
|
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]),
|
||||||
|
/// and how far a running timeline refresh has got reading photo locations
|
||||||
|
/// — `None` when none is reading any.
|
||||||
|
Places {
|
||||||
|
items: Vec<PlaceInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
mapping: Option<MappingProgress>,
|
||||||
|
},
|
||||||
|
/// The photos of one town, each with its location (reply to
|
||||||
|
/// [`Request::PlaceLocations`]).
|
||||||
|
PhotoLocations { items: Vec<PhotoLocation> },
|
||||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||||
Thumbs { items: Vec<PhotoThumb> },
|
Thumbs { items: Vec<PhotoThumb> },
|
||||||
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
|
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
|
||||||
@@ -2169,7 +2274,17 @@ mod tests {
|
|||||||
fn trash_requests_and_outcomes_roundtrip() {
|
fn trash_requests_and_outcomes_roundtrip() {
|
||||||
let request = Request::TrashNodes {
|
let request = Request::TrashNodes {
|
||||||
uids: vec!["volume~first".into(), "volume~second".into()],
|
uids: vec!["volume~first".into(), "volume~second".into()],
|
||||||
|
files_only: false,
|
||||||
};
|
};
|
||||||
|
// An older front-end's request trashes whole groups.
|
||||||
|
let old: Request = serde_json::from_str(r#"{"TrashNodes":{"uids":["v~a"]}}"#).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
old,
|
||||||
|
Request::TrashNodes {
|
||||||
|
files_only: false,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
let line = serde_json::to_string(&request).unwrap();
|
let line = serde_json::to_string(&request).unwrap();
|
||||||
assert!(!line.contains('\n'), "wire form must be a single line");
|
assert!(!line.contains('\n'), "wire form must be a single line");
|
||||||
let decoded: Request = serde_json::from_str(&line).unwrap();
|
let decoded: Request = serde_json::from_str(&line).unwrap();
|
||||||
@@ -2456,6 +2571,55 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A place listing and a place page cross the wire intact: a mangled id
|
||||||
|
/// would open the wrong town.
|
||||||
|
#[test]
|
||||||
|
fn places_requests_and_reply_roundtrip() {
|
||||||
|
for req in [
|
||||||
|
Request::PhotoPlaces,
|
||||||
|
Request::PlacePhotos {
|
||||||
|
id: 2950159,
|
||||||
|
offset: 60,
|
||||||
|
limit: 30,
|
||||||
|
},
|
||||||
|
Request::PlaceLocations { id: 2950159 },
|
||||||
|
] {
|
||||||
|
let line = serde_json::to_string(&req).unwrap();
|
||||||
|
let back: Request = serde_json::from_str(&line).unwrap();
|
||||||
|
assert_eq!(line, serde_json::to_string(&back).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = r#"{"Places":{"items":[{"id":2950159,"name":"Berlin","country":"DE","photo_count":2,
|
||||||
|
"cover":{"uid":"vol~b2","capture_time":1700000000,"thumb_path":null,"name":"b2.jpg","ratio":null,"no_thumb":false}}]}}"#;
|
||||||
|
let Response::Places { items, mapping } = serde_json::from_str(line).unwrap() else {
|
||||||
|
panic!("not a place listing");
|
||||||
|
};
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!((items[0].id, items[0].country.as_str()), (2950159, "DE"));
|
||||||
|
assert_eq!(items[0].cover.uid, "vol~b2");
|
||||||
|
// A daemon from before the progress field still parses: no mapping.
|
||||||
|
assert_eq!(mapping, None);
|
||||||
|
|
||||||
|
let reply = Response::Places {
|
||||||
|
items: Vec::new(),
|
||||||
|
mapping: Some(MappingProgress {
|
||||||
|
done: 120,
|
||||||
|
total: 480,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let line = serde_json::to_string(&reply).unwrap();
|
||||||
|
let Response::Places { mapping, .. } = serde_json::from_str(&line).unwrap() else {
|
||||||
|
panic!("not a place listing");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
mapping,
|
||||||
|
Some(MappingProgress {
|
||||||
|
done: 120,
|
||||||
|
total: 480
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The sharing and devices requests must survive the same line-delimited JSON
|
/// The sharing and devices requests must survive the same line-delimited JSON
|
||||||
/// round-trip: a mangled id or role would touch the wrong share or member.
|
/// round-trip: a mangled id or role would touch the wrong share or member.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2787,6 +2951,7 @@ mod tests {
|
|||||||
decoded,
|
decoded,
|
||||||
Request::PhotosTimeline {
|
Request::PhotosTimeline {
|
||||||
favorites: false,
|
favorites: false,
|
||||||
|
not_in_album: false,
|
||||||
kind: None,
|
kind: None,
|
||||||
range: None,
|
range: None,
|
||||||
..
|
..
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the kept log already holds an entry of `kind` for `target`. Lets
|
||||||
|
/// the daemon report something once across restarts rather than once per
|
||||||
|
/// run, without keeping a second table in step with the log.
|
||||||
|
pub fn activity_has(&self, kind: ActivityKind, target: &str) -> Result<bool> {
|
||||||
|
let kind = serde_json::to_string(&kind)?;
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT 1 FROM activity WHERE kind = ?1 AND target = ?2 LIMIT 1")?;
|
||||||
|
Ok(stmt.exists(params![kind, target])?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
||||||
/// whose stored `kind` no longer parses (written by an older build) are
|
/// whose stored `kind` no longer parses (written by an older build) are
|
||||||
/// skipped rather than failing the whole read.
|
/// skipped rather than failing the whole read.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 32;
|
pub(super) const SCHEMA_VERSION: i64 = 34;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -284,6 +284,38 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 33 {
|
||||||
|
// Same guards as V26-V31.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'place_id'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V33)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 34 {
|
||||||
|
// Same guards as V26-V33.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'similar_hash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V34)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -973,3 +1005,31 @@ UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
|||||||
/// decoder, which could only fail, and that failure was recorded as the
|
/// decoder, which could only fail, and that failure was recorded as the
|
||||||
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
|
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
|
||||||
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
|
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
|
||||||
|
|
||||||
|
/// Schema v33: where each photo was taken.
|
||||||
|
///
|
||||||
|
/// `latitude` and `longitude` come from the location other clients write into
|
||||||
|
/// a photo's extended attributes; `place_id` is the GeoNames id of the nearest
|
||||||
|
/// town (see `crate::places`), so the Places view is a plain `GROUP BY`.
|
||||||
|
///
|
||||||
|
/// Every photo is marked unresolved, so the next refresh reads the location of
|
||||||
|
/// the whole library once. That is the backfill: the metadata is on the node
|
||||||
|
/// the refresh reads anyway, and nothing has to be downloaded.
|
||||||
|
const MIGRATION_V33: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN latitude REAL;
|
||||||
|
ALTER TABLE photos ADD COLUMN longitude REAL;
|
||||||
|
ALTER TABLE photos ADD COLUMN place_id INTEGER;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
||||||
|
UPDATE photos SET resolved_at = NULL;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v34: what each photo looks like, for finding similar ones.
|
||||||
|
///
|
||||||
|
/// `similar_hash` is a 64-bit difference hash of the photo's thumbnail (see
|
||||||
|
/// `crate::similar`): resized and re-encoded copies of one picture land a few
|
||||||
|
/// bits apart, where the server's `content_hash` only matches byte-identical
|
||||||
|
/// files. `NULL` until the daemon has hashed the thumbnail, which it does as
|
||||||
|
/// thumbnails arrive and when the similar-photo finder asks for the rest.
|
||||||
|
const MIGRATION_V34: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||||
|
";
|
||||||
@@ -59,7 +59,7 @@ pub use ops::{
|
|||||||
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
||||||
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||||
};
|
};
|
||||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||||
pub use pins::PinRow;
|
pub use pins::PinRow;
|
||||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||||
pub use trash::StoredTrash;
|
pub use trash::StoredTrash;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -42,6 +42,15 @@ pub struct StoredPhoto {
|
|||||||
pub has_raw: bool,
|
pub has_raw: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||||
|
/// many photos it holds, and the newest of them.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct StoredPlace {
|
||||||
|
pub id: u32,
|
||||||
|
pub count: usize,
|
||||||
|
pub cover: StoredPhoto,
|
||||||
|
}
|
||||||
|
|
||||||
/// One row of a timeline replacement. Everything but the uid and the capture
|
/// One row of a timeline replacement. Everything but the uid and the capture
|
||||||
/// time is "what this refresh learned": a `None` keeps whatever is already
|
/// time is "what this refresh learned": a `None` keeps whatever is already
|
||||||
/// stored rather than clearing it, because the timeline DTO carries only the uid
|
/// stored rather than clearing it, because the timeline DTO carries only the uid
|
||||||
@@ -58,6 +67,8 @@ pub struct TimelineRow {
|
|||||||
/// The main photo this one is *related* to (a live-photo video, a burst
|
/// The main photo this one is *related* to (a live-photo video, a burst
|
||||||
/// sibling), when the server says it belongs to one.
|
/// sibling), when the server says it belongs to one.
|
||||||
pub main_uid: Option<String>,
|
pub main_uid: Option<String>,
|
||||||
|
/// Where the photo was taken, as `(latitude, longitude)` in decimal degrees.
|
||||||
|
pub location: Option<(f64, f64)>,
|
||||||
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
||||||
/// makes every other field authoritative — including a `None` that means
|
/// makes every other field authoritative — including a `None` that means
|
||||||
/// "the server no longer reports one" — while `None` means the refresh
|
/// "the server no longer reports one" — while `None` means the refresh
|
||||||
@@ -86,6 +97,9 @@ struct Learned {
|
|||||||
favorite: bool,
|
favorite: bool,
|
||||||
content_hash: Option<String>,
|
content_hash: Option<String>,
|
||||||
main_uid: Option<String>,
|
main_uid: Option<String>,
|
||||||
|
location: Option<(f64, f64)>,
|
||||||
|
/// What its thumbnail looks like (see [`crate::similar`]).
|
||||||
|
similar_hash: Option<i64>,
|
||||||
/// When this photo's node was last resolved, kept so a refresh that skips it
|
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||||
/// does not make it look unresolved again.
|
/// does not make it look unresolved again.
|
||||||
resolved_at: Option<i64>,
|
resolved_at: Option<i64>,
|
||||||
@@ -281,7 +295,7 @@ impl Db {
|
|||||||
let learned: HashMap<String, Learned> = {
|
let learned: HashMap<String, Learned> = {
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||||
main_uid, resolved_at FROM photos",
|
main_uid, resolved_at, latitude, longitude, similar_hash FROM photos",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
Ok((
|
Ok((
|
||||||
@@ -295,6 +309,10 @@ impl Db {
|
|||||||
content_hash: r.get(6)?,
|
content_hash: r.get(6)?,
|
||||||
main_uid: r.get(7)?,
|
main_uid: r.get(7)?,
|
||||||
resolved_at: r.get(8)?,
|
resolved_at: r.get(8)?,
|
||||||
|
location: r
|
||||||
|
.get::<_, Option<f64>>(9)?
|
||||||
|
.zip(r.get::<_, Option<f64>>(10)?),
|
||||||
|
similar_hash: r.get(11)?,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
@@ -317,14 +335,20 @@ impl Db {
|
|||||||
carried.name = row.name.clone();
|
carried.name = row.name.clone();
|
||||||
carried.media_type = row.media_type.clone();
|
carried.media_type = row.media_type.clone();
|
||||||
carried.favorite = row.favorite.unwrap_or(false);
|
carried.favorite = row.favorite.unwrap_or(false);
|
||||||
|
// New bytes may show another picture: hash it again.
|
||||||
|
if row.content_hash != carried.content_hash {
|
||||||
|
carried.similar_hash = None;
|
||||||
|
}
|
||||||
carried.content_hash = row.content_hash.clone();
|
carried.content_hash = row.content_hash.clone();
|
||||||
carried.main_uid = row.main_uid.clone();
|
carried.main_uid = row.main_uid.clone();
|
||||||
|
carried.location = row.location;
|
||||||
} else {
|
} else {
|
||||||
carried.name = row.name.clone().or(carried.name);
|
carried.name = row.name.clone().or(carried.name);
|
||||||
carried.media_type = row.media_type.clone().or(carried.media_type);
|
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||||
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||||
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||||
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
||||||
|
carried.location = row.location.or(carried.location);
|
||||||
}
|
}
|
||||||
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
||||||
// The tab this photo lands in is derived here, once, so a page or
|
// The tab this photo lands in is derived here, once, so a page or
|
||||||
@@ -355,8 +379,10 @@ impl Db {
|
|||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO photos
|
"INSERT INTO photos
|
||||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||||
content_hash, main_uid, group_key, resolved_at)
|
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
place_id, similar_hash)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||||
|
?16, ?17)",
|
||||||
)?;
|
)?;
|
||||||
for (seq, ((row, carried, kind), group_key)) in
|
for (seq, ((row, carried, kind), group_key)) in
|
||||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||||
@@ -375,6 +401,13 @@ impl Db {
|
|||||||
carried.main_uid,
|
carried.main_uid,
|
||||||
group_key,
|
group_key,
|
||||||
carried.resolved_at,
|
carried.resolved_at,
|
||||||
|
carried.location.map(|(lat, _)| lat),
|
||||||
|
carried.location.map(|(_, lon)| lon),
|
||||||
|
carried
|
||||||
|
.location
|
||||||
|
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
|
||||||
|
.map(|city| city.id),
|
||||||
|
carried.similar_hash,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,6 +480,14 @@ impl Db {
|
|||||||
albums.execute([uid])?;
|
albums.execute([uid])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A group whose representative went is left pointing at nothing, which
|
||||||
|
// drops its other files off every page. Make them stand alone until the
|
||||||
|
// next refresh groups them again.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE photos SET group_key = NULL WHERE group_key IS NOT NULL \
|
||||||
|
AND group_key NOT IN (SELECT uid FROM photos)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(removed)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
@@ -455,7 +496,8 @@ impl Db {
|
|||||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||||
/// `favorites` restricts the page to favorited photos.
|
/// `favorites` restricts the page to favorited photos, and `not_in_album`
|
||||||
|
/// to the shots no album holds any file of.
|
||||||
///
|
///
|
||||||
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
|
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
|
||||||
/// one tile. The Raw tab is the exception and lists every raw file, because
|
/// one tile. The Raw tab is the exception and lists every raw file, because
|
||||||
@@ -467,6 +509,7 @@ impl Db {
|
|||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
range: Option<(i64, i64)>,
|
range: Option<(i64, i64)>,
|
||||||
favorites: bool,
|
favorites: bool,
|
||||||
|
not_in_album: bool,
|
||||||
) -> Result<Vec<StoredPhoto>> {
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
// Built up so any combination of the optional filters is one indexed
|
// Built up so any combination of the optional filters is one indexed
|
||||||
@@ -490,6 +533,15 @@ impl Db {
|
|||||||
if favorites {
|
if favorites {
|
||||||
conds.push("p.favorite = 1".to_string());
|
conds.push("p.favorite = 1".to_string());
|
||||||
}
|
}
|
||||||
|
// By group, not by file: a shot filed as its JPEG is filed, even though
|
||||||
|
// its RAW was never added anywhere.
|
||||||
|
if not_in_album {
|
||||||
|
conds.push(
|
||||||
|
"NOT EXISTS (SELECT 1 FROM album_photos a JOIN photos m ON m.uid = a.uid \
|
||||||
|
WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid))"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
if !conds.is_empty() {
|
if !conds.is_empty() {
|
||||||
sql.push_str(" WHERE ");
|
sql.push_str(" WHERE ");
|
||||||
sql.push_str(&conds.join(" AND "));
|
sql.push_str(&conds.join(" AND "));
|
||||||
@@ -577,6 +629,256 @@ impl Db {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Photos taken on `today`'s calendar day in earlier years, newest first —
|
||||||
|
/// the "On this day" strip. `today` is epoch seconds: its local month and day
|
||||||
|
/// pick the date, and its own year is left out, because today's photos are
|
||||||
|
/// what the timeline already opens on. One row per group, like the timeline.
|
||||||
|
pub fn photos_on_this_day(&self, today: i64, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let sql = format!(
|
||||||
|
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) \
|
||||||
|
AND strftime('%m-%d', p.capture_time, 'unixepoch', 'localtime') \
|
||||||
|
= strftime('%m-%d', ?1, 'unixepoch', 'localtime') \
|
||||||
|
AND strftime('%Y', p.capture_time, 'unixepoch', 'localtime') \
|
||||||
|
< strftime('%Y', ?1, 'unixepoch', 'localtime') \
|
||||||
|
ORDER BY p.seq LIMIT ?2"
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![today, limit as i64], stored_photo)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every town photos were taken in, with how many and the newest one as its
|
||||||
|
/// cover, most photographed first. Counted per group, like the timeline;
|
||||||
|
/// photos without a location, or taken far from any town, are in none.
|
||||||
|
pub fn photo_places(&self) -> Result<Vec<StoredPlace>> {
|
||||||
|
let rows: Vec<(u32, usize, String)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
// SQLite fills the bare `uid` from the row `MIN(seq)` picked, which
|
||||||
|
// is the newest photo of the place.
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT place_id, COUNT(*), uid, MIN(seq) FROM photos \
|
||||||
|
WHERE uid = COALESCE(group_key, uid) AND place_id IS NOT NULL \
|
||||||
|
GROUP BY place_id ORDER BY COUNT(*) DESC, MIN(seq)",
|
||||||
|
)?;
|
||||||
|
stmt.query_map([], |r| {
|
||||||
|
Ok((r.get(0)?, r.get::<_, i64>(1)? as usize, r.get(2)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
let uids: Vec<String> = rows.iter().map(|(_, _, uid)| uid.clone()).collect();
|
||||||
|
let mut covers: HashMap<String, StoredPhoto> = self
|
||||||
|
.photos_by_uid(&uids)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|photo| (photo.uid.clone(), photo))
|
||||||
|
.collect();
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, count, uid)| {
|
||||||
|
Some(StoredPlace {
|
||||||
|
id,
|
||||||
|
count,
|
||||||
|
cover: covers.remove(&uid)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of the photos taken in a town, newest first, one per group.
|
||||||
|
pub fn photos_at_place(
|
||||||
|
&self,
|
||||||
|
id: u32,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let sql = format!(
|
||||||
|
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) AND p.place_id = ?1 \
|
||||||
|
ORDER BY p.seq LIMIT ?2 OFFSET ?3"
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![id, limit as i64, offset as i64], stored_photo)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every photo taken in a town, one per group as in [`Self::photos_at_place`],
|
||||||
|
/// with where exactly it was taken. A group's own location is that of the
|
||||||
|
/// photo standing for it.
|
||||||
|
pub fn photo_locations_at_place(&self, id: u32) -> Result<Vec<(StoredPhoto, (f64, f64))>> {
|
||||||
|
let photos = self.photos_at_place(id, 0, usize::MAX >> 1)?;
|
||||||
|
let located: HashMap<String, (f64, f64)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT uid, latitude, longitude FROM photos \
|
||||||
|
WHERE place_id = ?1 AND latitude IS NOT NULL AND longitude IS NOT NULL",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![id], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?))))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
Ok(photos
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|photo| {
|
||||||
|
let at = *located.get(&photo.uid)?;
|
||||||
|
Some((photo, at))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets of photos with the same content hash, each set ordered with the
|
||||||
|
/// copy worth keeping first, and the sets newest first.
|
||||||
|
///
|
||||||
|
/// The copy to keep is the one in the most albums, then the one that is part
|
||||||
|
/// of a larger group (the JPEG's RAW is filed with it, a stray re-upload is
|
||||||
|
/// not), then the earliest in server order. At most one file per group is
|
||||||
|
/// listed: the files of one RAW+JPEG shot are not copies of each other, and a
|
||||||
|
/// set left with one member is no set.
|
||||||
|
pub fn photo_duplicates(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||||
|
let rows: Vec<(String, String, String)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT p.uid, p.content_hash, COALESCE(p.group_key, p.uid) AS k FROM photos p \
|
||||||
|
WHERE p.content_hash IN (SELECT content_hash FROM photos \
|
||||||
|
WHERE content_hash IS NOT NULL GROUP BY content_hash HAVING COUNT(*) > 1) \
|
||||||
|
ORDER BY p.content_hash, \
|
||||||
|
(SELECT COUNT(*) FROM album_photos a WHERE a.uid = p.uid) DESC, \
|
||||||
|
(SELECT COUNT(*) FROM photos m WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid)) DESC, \
|
||||||
|
p.seq",
|
||||||
|
)?;
|
||||||
|
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
let uids: Vec<String> = rows.iter().map(|(uid, _, _)| uid.clone()).collect();
|
||||||
|
let mut stored: HashMap<String, StoredPhoto> = self
|
||||||
|
.photos_by_uid(&uids)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|photo| (photo.uid.clone(), photo))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut sets: Vec<Vec<StoredPhoto>> = Vec::new();
|
||||||
|
let mut hash_of_set: Option<&str> = None;
|
||||||
|
let mut groups_in_set: HashSet<&str> = HashSet::new();
|
||||||
|
for (uid, hash, group) in &rows {
|
||||||
|
if hash_of_set != Some(hash.as_str()) {
|
||||||
|
hash_of_set = Some(hash);
|
||||||
|
groups_in_set.clear();
|
||||||
|
sets.push(Vec::new());
|
||||||
|
}
|
||||||
|
if !groups_in_set.insert(group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let (Some(set), Some(photo)) = (sets.last_mut(), stored.remove(uid)) {
|
||||||
|
set.push(photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sets.retain(|set| set.len() > 1);
|
||||||
|
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||||
|
Ok(sets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shots whose look hasn't been hashed yet, by the uid of the photo the
|
||||||
|
/// grid shows for each, newest first, at most `limit`. Videos, and photos
|
||||||
|
/// with no thumbnail to hash, are left out. A shot counts as hashed once
|
||||||
|
/// any of its files is, since they all show one picture.
|
||||||
|
pub fn photos_unhashed(&self, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let uids: Vec<String> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(group_key, uid) AS k FROM photos \
|
||||||
|
GROUP BY k \
|
||||||
|
HAVING MAX(similar_hash) IS NULL \
|
||||||
|
AND MAX(kind != ?1 AND thumb_state != ?2) \
|
||||||
|
ORDER BY MAX(capture_time) DESC LIMIT ?3",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(
|
||||||
|
params![
|
||||||
|
crate::control::PhotoKind::Video.as_i64(),
|
||||||
|
THUMB_NONE,
|
||||||
|
limit as i64
|
||||||
|
],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
self.photos_by_uid(&uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the look of photo `uid`: its difference hash, or
|
||||||
|
/// [`crate::similar::NO_HASH`] when its thumbnail gave none.
|
||||||
|
pub fn photo_set_similar_hash(&self, uid: &str, hash: u64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE photos SET similar_hash = ?2 WHERE uid = ?1",
|
||||||
|
params![uid, hash as i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many shots have been hashed, out of how many could be.
|
||||||
|
pub fn similar_progress(&self) -> Result<(usize, usize)> {
|
||||||
|
let conn = self.read();
|
||||||
|
let (hashed, total): (i64, i64) = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FILTER (WHERE hashed), COUNT(*) FROM \
|
||||||
|
(SELECT MAX(similar_hash) IS NOT NULL AS hashed FROM photos \
|
||||||
|
GROUP BY COALESCE(group_key, uid) \
|
||||||
|
HAVING MAX(similar_hash) IS NOT NULL \
|
||||||
|
OR MAX(kind != ?1 AND thumb_state != ?2))",
|
||||||
|
params![crate::control::PhotoKind::Video.as_i64(), THUMB_NONE],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
Ok((hashed.max(0) as usize, total.max(0) as usize))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets of shots that look alike (see [`crate::similar`]), one photo per
|
||||||
|
/// shot in capture order, and the sets newest first. A set whose photos
|
||||||
|
/// are all byte-identical copies is left to [`Self::photo_duplicates`].
|
||||||
|
pub fn photo_similar(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||||
|
let rows: Vec<(String, u64, Option<String>)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(group_key, uid) AS k, MAX(similar_hash), \
|
||||||
|
MAX(content_hash) FILTER (WHERE uid = COALESCE(group_key, uid)) \
|
||||||
|
FROM photos GROUP BY k \
|
||||||
|
HAVING MAX(similar_hash) IS NOT NULL AND MAX(similar_hash) != ?1",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![crate::similar::NO_HASH as i64], |r| {
|
||||||
|
Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
let hashes: Vec<u64> = rows.iter().map(|(_, hash, _)| *hash).collect();
|
||||||
|
let found: Vec<Vec<usize>> = crate::similar::sets(&hashes)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|set| {
|
||||||
|
let first = &rows[set[0]].2;
|
||||||
|
first.is_none() || set.iter().any(|&i| rows[i].2 != *first)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let uids: Vec<String> = found.iter().flatten().map(|&i| rows[i].0.clone()).collect();
|
||||||
|
let mut stored: HashMap<String, StoredPhoto> = self
|
||||||
|
.photos_by_uid(&uids)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|photo| (photo.uid.clone(), photo))
|
||||||
|
.collect();
|
||||||
|
let mut sets: Vec<Vec<StoredPhoto>> = found
|
||||||
|
.iter()
|
||||||
|
.map(|set| {
|
||||||
|
let mut photos: Vec<StoredPhoto> = set
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&i| stored.remove(&rows[i].0))
|
||||||
|
.collect();
|
||||||
|
photos.sort_by_key(|p| p.capture_time);
|
||||||
|
photos
|
||||||
|
})
|
||||||
|
.filter(|set| set.len() > 1)
|
||||||
|
.collect();
|
||||||
|
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||||
|
Ok(sets)
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||||
use crate::control::PhotoKind;
|
use crate::control::PhotoKind;
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p0", "p1", "p2"],
|
["p0", "p1", "p2"],
|
||||||
@@ -163,7 +163,14 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
// The counts break down by tab, and a filtered page returns only its tab.
|
// The counts break down by tab, and a filtered page returns only its tab.
|
||||||
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
||||||
let videos = db
|
let videos = db
|
||||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false)
|
.photos_page(
|
||||||
|
0,
|
||||||
|
10,
|
||||||
|
Some(crate::control::PhotoKind::Video),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
@@ -176,7 +183,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
|
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
|
||||||
// not p0 at 400. Combined with a kind filter both conditions apply.
|
// not p0 at 400. Combined with a kind filter both conditions apply.
|
||||||
let ranged = db
|
let ranged = db
|
||||||
.photos_page(0, 10, None, Some((150, 350)), false)
|
.photos_page(0, 10, None, Some((150, 350)), false, false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
@@ -189,6 +196,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
Some(crate::control::PhotoKind::Video),
|
Some(crate::control::PhotoKind::Video),
|
||||||
Some((150, 350)),
|
Some((150, 350)),
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -218,7 +226,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p1"],
|
["p1"],
|
||||||
@@ -236,7 +244,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
|||||||
TimelineRow::new("p2", 200),
|
TimelineRow::new("p2", 200),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p2"],
|
["p2"],
|
||||||
@@ -245,6 +253,305 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
|||||||
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
|
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The strip shows the same calendar day in earlier years only: not today's
|
||||||
|
/// own photos, not the day before, and one tile per RAW+JPEG shot.
|
||||||
|
#[test]
|
||||||
|
fn on_this_day_finds_the_same_date_in_earlier_years() {
|
||||||
|
const DAY: i64 = 86_400;
|
||||||
|
// Noon UTC, so the local date is the same in every common time zone.
|
||||||
|
let today = 1_781_524_800; // 2026-06-15 12:00 UTC
|
||||||
|
let year_ago = today - 365 * DAY; // 2025-06-15
|
||||||
|
let three_years_ago = today - (365 * 3 + 1) * DAY; // 2023-06-15, past a 29 February
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow::new("today", today),
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1.JPG".into()),
|
||||||
|
..TimelineRow::new("jpeg", year_ago)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1.CR2".into()),
|
||||||
|
..TimelineRow::new("raw", year_ago)
|
||||||
|
},
|
||||||
|
TimelineRow::new("day-before", year_ago - DAY),
|
||||||
|
TimelineRow::new("older", three_years_ago),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let found = db.photos_on_this_day(today, 10).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
found.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["jpeg", "older"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hashed(uid: &str, capture_time: i64, name: &str, hash: &str) -> TimelineRow {
|
||||||
|
TimelineRow {
|
||||||
|
name: Some(name.into()),
|
||||||
|
content_hash: Some(hash.into()),
|
||||||
|
..TimelineRow::new(uid, capture_time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn located(uid: &str, capture_time: i64, name: &str, at: (f64, f64)) -> TimelineRow {
|
||||||
|
TimelineRow {
|
||||||
|
name: Some(name.into()),
|
||||||
|
location: Some(at),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new(uid, capture_time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BERLIN: (f64, f64) = (52.52, 13.405);
|
||||||
|
const PARIS: (f64, f64) = (48.857, 2.352);
|
||||||
|
|
||||||
|
/// Places count shots, not files, lead with the most photographed town, and
|
||||||
|
/// leave out photos with no location or taken far from any town.
|
||||||
|
#[test]
|
||||||
|
fn places_group_photos_by_the_nearest_town() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
located("b2", 500, "IMG_5.JPG", BERLIN),
|
||||||
|
located("p1", 400, "IMG_4.JPG", PARIS),
|
||||||
|
located("b1-jpeg", 300, "IMG_3.JPG", BERLIN),
|
||||||
|
located("b1-raw", 300, "IMG_3.CR2", BERLIN),
|
||||||
|
located("sea", 200, "IMG_2.JPG", (0.0, -30.0)),
|
||||||
|
TimelineRow::new("nowhere", 100),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let places = db.photo_places().unwrap();
|
||||||
|
let summary: Vec<(&str, usize, &str)> = places
|
||||||
|
.iter()
|
||||||
|
.map(|place| {
|
||||||
|
let city = crate::places::city(place.id).expect("a known town");
|
||||||
|
(city.name, place.count, place.cover.uid.as_str())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(summary, [("Berlin", 2, "b2"), ("Paris", 1, "p1")]);
|
||||||
|
|
||||||
|
let berlin: Vec<String> = db
|
||||||
|
.photos_at_place(places[0].id, 0, 10)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.uid)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(berlin, ["b2", "b1-jpeg"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A town's photos come with where each was taken, one per RAW+JPEG shot.
|
||||||
|
#[test]
|
||||||
|
fn place_photos_carry_their_own_location() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let kreuzberg = (52.497, 13.411);
|
||||||
|
db.photos_replace(&[
|
||||||
|
located("b2", 500, "IMG_5.JPG", BERLIN),
|
||||||
|
located("b1-jpeg", 300, "IMG_3.JPG", kreuzberg),
|
||||||
|
located("b1-raw", 300, "IMG_3.CR2", kreuzberg),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let id = db.photo_places().unwrap()[0].id;
|
||||||
|
let located: Vec<(String, (f64, f64))> = db
|
||||||
|
.photo_locations_at_place(id)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(photo, at)| (photo.uid, at))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
located,
|
||||||
|
[("b2".to_owned(), BERLIN), ("b1-jpeg".to_owned(), kreuzberg)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
||||||
|
/// that reads the node again takes what the node now says.
|
||||||
|
#[test]
|
||||||
|
fn a_skipped_refresh_keeps_a_photos_location() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[located("a", 100, "IMG_1.JPG", BERLIN)])
|
||||||
|
.unwrap();
|
||||||
|
db.photos_replace(&[TimelineRow::new("a", 100)]).unwrap();
|
||||||
|
assert_eq!(db.photo_places().unwrap().len(), 1);
|
||||||
|
|
||||||
|
db.photos_replace(&[TimelineRow {
|
||||||
|
resolved_at: Some(2),
|
||||||
|
..TimelineRow::new("a", 100)
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
assert!(db.photo_places().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy an album holds is the one kept; the files of one RAW+JPEG shot are
|
||||||
|
/// never copies of each other; a hash only one file carries is no set.
|
||||||
|
#[test]
|
||||||
|
fn duplicates_list_the_copy_to_keep_first() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
hashed("copy", 500, "IMG_2.JPG", "h2"),
|
||||||
|
hashed("filed", 400, "IMG_2(1).JPG", "h2"),
|
||||||
|
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
|
||||||
|
hashed("raw", 300, "IMG_1.CR2", "h1"),
|
||||||
|
hashed("stray", 200, "IMG_1(1).CR2", "h1"),
|
||||||
|
hashed("alone", 100, "IMG_3.JPG", "h3"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.album_photos_replace("a1", &[("filed".into(), 400, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sets = db.photo_duplicates().unwrap();
|
||||||
|
let uids: Vec<Vec<&str>> = sets
|
||||||
|
.iter()
|
||||||
|
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shots that look alike form a set, one photo per shot, while a set of
|
||||||
|
/// byte-identical copies is left to the duplicate finder.
|
||||||
|
#[test]
|
||||||
|
fn similar_photos_group_by_look_and_skip_exact_copies() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
hashed("big", 500, "IMG_1.JPG", "h1"),
|
||||||
|
hashed("small", 400, "IMG_1-small.JPG", "h2"),
|
||||||
|
hashed("copy", 300, "IMG_2.JPG", "h3"),
|
||||||
|
hashed("copy2", 200, "IMG_2(1).JPG", "h3"),
|
||||||
|
hashed("other", 100, "IMG_3.JPG", "h4"),
|
||||||
|
hashed("flat", 50, "IMG_4.JPG", "h5"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let look = 0x0f0f_3c3c_5a5a_9696_u64;
|
||||||
|
db.photo_set_similar_hash("big", look).unwrap();
|
||||||
|
db.photo_set_similar_hash("small", look ^ 0b101).unwrap();
|
||||||
|
db.photo_set_similar_hash("copy", !look).unwrap();
|
||||||
|
db.photo_set_similar_hash("copy2", !look).unwrap();
|
||||||
|
db.photo_set_similar_hash("other", look ^ 0xffff_ffff)
|
||||||
|
.unwrap();
|
||||||
|
db.photo_set_similar_hash("flat", crate::similar::NO_HASH)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sets = db.photo_similar().unwrap();
|
||||||
|
let uids: Vec<Vec<&str>> = sets
|
||||||
|
.iter()
|
||||||
|
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(uids, [vec!["small", "big"]]);
|
||||||
|
assert_eq!(db.similar_progress().unwrap(), (6, 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is left to hash skips videos and hashed shots, and a hash survives a
|
||||||
|
/// refresh unless the photo's bytes changed.
|
||||||
|
#[test]
|
||||||
|
fn similar_hashes_are_kept_until_the_photo_changes() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
let resolved = |uid: &str, hash: &str| TimelineRow {
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..hashed(uid, 100, &format!("{uid}.JPG"), hash)
|
||||||
|
};
|
||||||
|
db.photos_replace(&[
|
||||||
|
resolved("a", "h1"),
|
||||||
|
resolved("b", "h2"),
|
||||||
|
hashed("clip", 50, "clip.mp4", "h3"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.photo_set_similar_hash("a", 7).unwrap();
|
||||||
|
db.photo_set_similar_hash("b", 9).unwrap();
|
||||||
|
let left = |db: &Db| -> Vec<String> {
|
||||||
|
db.photos_unhashed(10)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| p.uid)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert!(left(&db).is_empty());
|
||||||
|
assert_eq!(db.similar_progress().unwrap(), (2, 2));
|
||||||
|
|
||||||
|
db.photos_replace(&[
|
||||||
|
resolved("a", "h1"),
|
||||||
|
resolved("b", "h2-edited"),
|
||||||
|
hashed("clip", 50, "clip.mp4", "h3"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(left(&db), ["b"]);
|
||||||
|
assert_eq!(db.similar_progress().unwrap(), (1, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trashing the file a group is shown as leaves the rest of the group on the
|
||||||
|
/// timeline instead of pointing at a photo that is gone.
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_group_representative_keeps_its_other_files() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
|
||||||
|
hashed("raw", 300, "IMG_1.CR2", "h2"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.photos_page(0, 10, None, None, false, false)
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
db.photos_delete(&["jpeg".into()]).unwrap();
|
||||||
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["raw"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_in_album_hides_photos_any_album_holds() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow::new("p1", 300),
|
||||||
|
TimelineRow::new("p2", 200),
|
||||||
|
TimelineRow::new("p3", 100),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
db.album_photos_replace("a2", &[("p3".into(), 100, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let page = db.photos_page(0, 10, None, None, false, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["p2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filing the JPEG of a RAW+JPEG shot files the shot: the grid shows it as
|
||||||
|
/// one tile, and that tile is in an album.
|
||||||
|
#[test]
|
||||||
|
fn not_in_album_counts_a_raw_pair_as_filed() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.JPG".into()),
|
||||||
|
..TimelineRow::new("jpeg", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("img_1234.cr2".into()),
|
||||||
|
..TimelineRow::new("raw", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_9999.JPG".into()),
|
||||||
|
..TimelineRow::new("alone", 200)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.album_photos_replace("a1", &[("raw".into(), 300, None, None)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let page = db.photos_page(0, 10, None, None, false, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["alone"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn photos_page_slices_the_timeline_in_order() {
|
fn photos_page_slices_the_timeline_in_order() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -253,12 +560,16 @@ fn photos_page_slices_the_timeline_in_order() {
|
|||||||
.collect();
|
.collect();
|
||||||
db.photos_replace(&items).unwrap();
|
db.photos_replace(&items).unwrap();
|
||||||
|
|
||||||
let page = db.photos_page(2, 2, None, None, false).unwrap();
|
let page = db.photos_page(2, 2, None, None, false, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p2", "p3"]
|
["p2", "p3"]
|
||||||
);
|
);
|
||||||
assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty());
|
assert!(
|
||||||
|
db.photos_page(9, 2, None, None, false, false)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1414,6 +1725,29 @@ fn activity_reads_back_newest_first() {
|
|||||||
assert_eq!(db.activity_list(1).unwrap().len(), 1);
|
assert_eq!(db.activity_list(1).unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activity_has_finds_only_matching_kind_and_target() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.activity_add(&activity(
|
||||||
|
"a (sync-conflict 1).txt",
|
||||||
|
ActivityKind::Conflict,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
db.activity_add(&activity("b.txt", ActivityKind::Upload, true))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
db.activity_has(ActivityKind::Conflict, "a (sync-conflict 1).txt")
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!db.activity_has(ActivityKind::Upload, "a (sync-conflict 1).txt")
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(!db.activity_has(ActivityKind::Conflict, "b.txt").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activity_prunes_to_the_keep_limit() {
|
fn activity_prunes_to_the_keep_limit() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -4054,7 +4388,7 @@ fn photos_delete_removes_the_photo_and_its_album_membership() {
|
|||||||
|
|
||||||
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1);
|
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1);
|
||||||
|
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p1", "p3"],
|
["p1", "p3"],
|
||||||
@@ -4094,7 +4428,7 @@ fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["jpeg", "alone"],
|
["jpeg", "alone"],
|
||||||
@@ -4107,7 +4441,14 @@ fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
|
|||||||
|
|
||||||
// The Raw tab answers "which raws do I have", so it lists files.
|
// The Raw tab answers "which raws do I have", so it lists files.
|
||||||
let raws = db
|
let raws = db
|
||||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Raw), None, false)
|
.photos_page(
|
||||||
|
0,
|
||||||
|
10,
|
||||||
|
Some(crate::control::PhotoKind::Raw),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
@@ -4152,7 +4493,7 @@ fn a_pixel_raw_capture_groups_with_its_cover_jpeg() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["cover", "notes", "other"],
|
["cover", "notes", "other"],
|
||||||
@@ -4191,7 +4532,7 @@ fn a_shared_name_alone_does_not_group_photos() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(page.len(), 3, "three shots, three tiles");
|
assert_eq!(page.len(), 3, "three shots, three tiles");
|
||||||
assert!(page.iter().all(|p| p.group_size == 1));
|
assert!(page.iter().all(|p| p.group_size == 1));
|
||||||
}
|
}
|
||||||
@@ -4214,7 +4555,7 @@ fn the_servers_photo_relation_groups_a_live_photo() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["main"]
|
["main"]
|
||||||
@@ -4316,7 +4657,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let db = Db::open(&path).unwrap();
|
let db = Db::open(&path).unwrap();
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["jpeg", "raw"],
|
["jpeg", "raw"],
|
||||||
@@ -4337,7 +4678,7 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(page.len(), 1);
|
assert_eq!(page.len(), 1);
|
||||||
assert_eq!(page[0].uid, "jpeg");
|
assert_eq!(page[0].uid, "jpeg");
|
||||||
|
|
||||||
@@ -4366,14 +4707,14 @@ fn photos_replace_keeps_a_skipped_photos_name() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(page.len(), 1, "one shot, two files");
|
assert_eq!(page.len(), 1, "one shot, two files");
|
||||||
assert!(page[0].has_raw);
|
assert!(page[0].has_raw);
|
||||||
|
|
||||||
// The next refresh resolves neither, because both are already resolved.
|
// The next refresh resolves neither, because both are already resolved.
|
||||||
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
|
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||||
assert_eq!(page.len(), 1, "the group survives a skipped refresh");
|
assert_eq!(page.len(), 1, "the group survives a skipped refresh");
|
||||||
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
|
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
|
||||||
assert!(page[0].has_raw, "the raw file is still a raw file");
|
assert!(page[0].has_raw, "the raw file is still a raw file");
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ pub mod localindex;
|
|||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
pub mod opener;
|
pub mod opener;
|
||||||
|
pub mod places;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod sdkcache;
|
pub mod sdkcache;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod similar;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
//! Offline reverse geocoding: the town a photo was taken in, from its GPS
|
||||||
|
//! position, without asking any service.
|
||||||
|
//!
|
||||||
|
//! The towns are GeoNames' `cities15000` dump (every place with more than
|
||||||
|
//! 15,000 people), trimmed to id, name, position and country in
|
||||||
|
//! `data/cities15000.tsv` by `scripts/geonames-cities.py`. The script drops the
|
||||||
|
//! parts of a city, such as Kreuzberg or "Paris 15 Vaugirard", so that a photo
|
||||||
|
//! lands in the town people would name. GeoNames publishes the dump under
|
||||||
|
//! CC BY 4.0, which is why the About dialog credits it.
|
||||||
|
//!
|
||||||
|
//! `data/countries.tsv` maps each country code to its ISO 3166-1 English name,
|
||||||
|
//! as the `iso-codes` project spells it, so a front end can translate it
|
||||||
|
//! through that project's `iso_3166-1` gettext domain.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// How far from the nearest town a photo may be and still count as taken
|
||||||
|
/// there, in km. Farther out (at sea, in the wilderness) it has no place.
|
||||||
|
pub const MAX_DISTANCE_KM: f64 = 50.0;
|
||||||
|
|
||||||
|
/// Mean Earth radius in km, for the haversine distance.
|
||||||
|
const EARTH_RADIUS_KM: f64 = 6371.0;
|
||||||
|
|
||||||
|
const CITIES: &str = include_str!("../data/cities15000.tsv");
|
||||||
|
const COUNTRIES: &str = include_str!("../data/countries.tsv");
|
||||||
|
|
||||||
|
/// One town of the table.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct City {
|
||||||
|
/// The GeoNames id, stable across dumps.
|
||||||
|
pub id: u32,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub latitude: f64,
|
||||||
|
pub longitude: f64,
|
||||||
|
/// ISO 3166-1 alpha-2 country code.
|
||||||
|
pub country: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The towns, and an index of them by whole-degree cell.
|
||||||
|
struct Table {
|
||||||
|
cities: Vec<City>,
|
||||||
|
cells: HashMap<(i32, i32), Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table() -> &'static Table {
|
||||||
|
static TABLE: OnceLock<Table> = OnceLock::new();
|
||||||
|
TABLE.get_or_init(|| {
|
||||||
|
let cities: Vec<City> = CITIES.lines().filter_map(parse_city).collect();
|
||||||
|
let mut cells: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
|
||||||
|
for (index, city) in cities.iter().enumerate() {
|
||||||
|
cells
|
||||||
|
.entry(cell(city.latitude, city.longitude))
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
Table { cities, cells }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_city(line: &'static str) -> Option<City> {
|
||||||
|
let mut fields = line.split('\t');
|
||||||
|
Some(City {
|
||||||
|
id: fields.next()?.parse().ok()?,
|
||||||
|
name: fields.next()?,
|
||||||
|
latitude: fields.next()?.parse().ok()?,
|
||||||
|
longitude: fields.next()?.parse().ok()?,
|
||||||
|
country: fields.next()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell(latitude: f64, longitude: f64) -> (i32, i32) {
|
||||||
|
(latitude.floor() as i32, longitude.floor() as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Great-circle distance between two positions, in km.
|
||||||
|
pub fn distance_km(a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||||
|
let (lat1, lat2) = (a.0.to_radians(), b.0.to_radians());
|
||||||
|
let dlat = lat2 - lat1;
|
||||||
|
let dlon = (b.1 - a.1).to_radians();
|
||||||
|
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
||||||
|
2.0 * EARTH_RADIUS_KM * h.sqrt().min(1.0).asin()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The town nearest to a position, if one lies within [`MAX_DISTANCE_KM`].
|
||||||
|
pub fn nearest(latitude: f64, longitude: f64) -> Option<&'static City> {
|
||||||
|
if !latitude.is_finite() || !longitude.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let table = table();
|
||||||
|
// A degree of latitude is about 111 km everywhere; a degree of longitude
|
||||||
|
// shrinks towards the poles, so more cells are needed to cover the radius.
|
||||||
|
let lat_span = (MAX_DISTANCE_KM / 111.0).ceil() as i32;
|
||||||
|
let lon_span = {
|
||||||
|
let scale = latitude.to_radians().cos().abs().max(0.01);
|
||||||
|
((MAX_DISTANCE_KM / (111.0 * scale)).ceil() as i32).min(180)
|
||||||
|
};
|
||||||
|
let (row, column) = cell(latitude, longitude);
|
||||||
|
let mut best: Option<(f64, &City)> = None;
|
||||||
|
for dlat in -lat_span..=lat_span {
|
||||||
|
for dlon in -lon_span..=lon_span {
|
||||||
|
// Longitude wraps at the antimeridian: -181 is 179.
|
||||||
|
let lon_cell = (column + dlon + 180).rem_euclid(360) - 180;
|
||||||
|
let Some(indices) = table.cells.get(&(row + dlat, lon_cell)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for &index in indices {
|
||||||
|
let city = &table.cities[index];
|
||||||
|
let d = distance_km((latitude, longitude), (city.latitude, city.longitude));
|
||||||
|
if d <= MAX_DISTANCE_KM && best.is_none_or(|(bd, _)| d < bd) {
|
||||||
|
best = Some((d, city));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, city)| city)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The town with this GeoNames id.
|
||||||
|
pub fn city(id: u32) -> Option<&'static City> {
|
||||||
|
let cities = &table().cities;
|
||||||
|
// The table is sorted by id.
|
||||||
|
cities
|
||||||
|
.binary_search_by_key(&id, |city| city.id)
|
||||||
|
.ok()
|
||||||
|
.map(|index| &cities[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The English ISO 3166-1 name of a country code, such as "Germany" for "DE".
|
||||||
|
pub fn country_name(code: &str) -> Option<&'static str> {
|
||||||
|
COUNTRIES.lines().find_map(|line| {
|
||||||
|
let (c, name) = line.split_once('\t')?;
|
||||||
|
(c == code).then_some(name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_position_in_a_town_resolves_to_that_town() {
|
||||||
|
let berlin = nearest(52.52, 13.405).expect("Berlin is a town");
|
||||||
|
assert_eq!(berlin.name, "Berlin");
|
||||||
|
assert_eq!(berlin.country, "DE");
|
||||||
|
assert_eq!(city(berlin.id), Some(berlin));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_open_sea_has_no_town() {
|
||||||
|
assert_eq!(nearest(0.0, -30.0), None);
|
||||||
|
assert_eq!(nearest(f64::NAN, 0.0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_town_across_the_antimeridian_is_still_found() {
|
||||||
|
// Just east of 180°, the nearest towns in Fiji are west of it.
|
||||||
|
let town = nearest(-16.8, 179.99);
|
||||||
|
assert!(town.is_none_or(|t| t.country == "FJ"));
|
||||||
|
let suva = nearest(-18.14, 178.44).expect("Suva is a town");
|
||||||
|
assert_eq!(suva.country, "FJ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_town_has_a_named_country() {
|
||||||
|
for city in &table().cities {
|
||||||
|
assert!(
|
||||||
|
country_name(city.country).is_some(),
|
||||||
|
"{} has no country name for {}",
|
||||||
|
city.name,
|
||||||
|
city.country
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(country_name("DE"), Some("Germany"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distances_are_great_circle_km() {
|
||||||
|
let d = distance_km((52.52, 13.405), (48.137, 11.575));
|
||||||
|
assert!(
|
||||||
|
(d - 504.0).abs() < 5.0,
|
||||||
|
"Berlin to Munich is about 504 km, got {d}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! Telling photos apart by what they show rather than by their bytes.
|
||||||
|
//!
|
||||||
|
//! The server's content hash only matches byte-identical files. A photo that
|
||||||
|
//! was resized, re-encoded or sent through a messenger is another file, so the
|
||||||
|
//! similar-photo finder hashes each thumbnail instead: a 64-bit difference
|
||||||
|
//! hash ("dHash"), which compares neighbouring brightness in a 9×8 grid. Two
|
||||||
|
//! copies of one picture land a few bits apart, and different pictures about
|
||||||
|
//! half of the bits apart.
|
||||||
|
|
||||||
|
/// Hashes this many bits apart or fewer show the same picture.
|
||||||
|
pub const SIMILAR_BITS: u32 = 6;
|
||||||
|
|
||||||
|
/// Stored for a photo whose thumbnail was looked at and gave no usable hash
|
||||||
|
/// (it couldn't be decoded, or is one flat colour), so it isn't looked at
|
||||||
|
/// again. A real hash is never 0: a picture that would hash to 0 has no
|
||||||
|
/// brightness changes, which [`difference_hash`] turns down as flat.
|
||||||
|
pub const NO_HASH: u64 = 0;
|
||||||
|
|
||||||
|
/// Samples of the grid that differ by less than this in brightness, all of
|
||||||
|
/// them, make a flat picture — a black frame, a blank page — whose hash is
|
||||||
|
/// noise and would match every other flat picture.
|
||||||
|
const FLAT: u8 = 8;
|
||||||
|
|
||||||
|
/// The difference hash of an 8-bit greyscale image `width` px wide, rows
|
||||||
|
/// stored one after another in `luma`. `None` for an empty or flat image.
|
||||||
|
pub fn difference_hash(width: usize, height: usize, luma: &[u8]) -> Option<u64> {
|
||||||
|
if width == 0 || height == 0 || luma.len() < width * height {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Box-average down to 9×8, so every source pixel counts once and the hash
|
||||||
|
// doesn't depend on which pixels a nearest-neighbour pick happens to hit.
|
||||||
|
let mut grid = [[0u8; 9]; 8];
|
||||||
|
for (gy, row) in grid.iter_mut().enumerate() {
|
||||||
|
let (y0, y1) = span(gy, 8, height);
|
||||||
|
for (gx, cell) in row.iter_mut().enumerate() {
|
||||||
|
let (x0, x1) = span(gx, 9, width);
|
||||||
|
let mut sum = 0u64;
|
||||||
|
for y in y0..y1 {
|
||||||
|
sum += luma[y * width + x0..y * width + x1]
|
||||||
|
.iter()
|
||||||
|
.map(|&v| u64::from(v))
|
||||||
|
.sum::<u64>();
|
||||||
|
}
|
||||||
|
*cell = (sum / ((y1 - y0) * (x1 - x0)) as u64) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (min, max) = grid
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.fold((u8::MAX, 0), |(min, max), &v| (min.min(v), max.max(v)));
|
||||||
|
if max - min < FLAT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut hash = 0u64;
|
||||||
|
for row in &grid {
|
||||||
|
for pair in row.windows(2) {
|
||||||
|
hash = hash << 1 | u64::from(pair[0] < pair[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(hash != NO_HASH).then_some(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source pixels `from..to` that cell `i` of `cells` covers along an edge
|
||||||
|
/// of `len` px; never empty, even for an edge shorter than the grid.
|
||||||
|
fn span(i: usize, cells: usize, len: usize) -> (usize, usize) {
|
||||||
|
let from = (i * len / cells).min(len - 1);
|
||||||
|
let to = ((i + 1) * len / cells).max(from + 1);
|
||||||
|
(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two hashes show the same picture.
|
||||||
|
pub fn alike(a: u64, b: u64) -> bool {
|
||||||
|
(a ^ b).count_ones() <= SIMILAR_BITS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group `hashes` into sets of alike ones, by index, each set in index order
|
||||||
|
/// and the sets by their first index. A hash alike to none other is in no
|
||||||
|
/// set. Alike is taken as catching: a burst of shots each a little different
|
||||||
|
/// from the last is one set.
|
||||||
|
pub fn sets(hashes: &[u64]) -> Vec<Vec<usize>> {
|
||||||
|
// Two hashes at most SIMILAR_BITS apart agree on at least one of their
|
||||||
|
// eight bytes, as long as SIMILAR_BITS < 8. So only hashes sharing some
|
||||||
|
// byte are compared, not every pair of the library.
|
||||||
|
const _: () = assert!(SIMILAR_BITS < 8);
|
||||||
|
let mut parent: Vec<usize> = (0..hashes.len()).collect();
|
||||||
|
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||||
|
while parent[i] != i {
|
||||||
|
parent[i] = parent[parent[i]];
|
||||||
|
i = parent[i];
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
for byte in 0..8 {
|
||||||
|
let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); 256];
|
||||||
|
for (i, &hash) in hashes.iter().enumerate() {
|
||||||
|
buckets[(hash >> (byte * 8)) as u8 as usize].push(i);
|
||||||
|
}
|
||||||
|
for bucket in &buckets {
|
||||||
|
for (n, &a) in bucket.iter().enumerate() {
|
||||||
|
for &b in &bucket[n + 1..] {
|
||||||
|
if alike(hashes[a], hashes[b]) {
|
||||||
|
let (ra, rb) = (root(&mut parent, a), root(&mut parent, b));
|
||||||
|
if ra != rb {
|
||||||
|
parent[ra.max(rb)] = ra.min(rb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut by_root: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
|
||||||
|
for i in 0..hashes.len() {
|
||||||
|
let r = root(&mut parent, i);
|
||||||
|
by_root.entry(r).or_default().push(i);
|
||||||
|
}
|
||||||
|
by_root.into_values().filter(|set| set.len() > 1).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A 90×80 picture: a bright disc on a gradient.
|
||||||
|
fn picture(width: usize, height: usize) -> Vec<u8> {
|
||||||
|
let mut luma = Vec::with_capacity(width * height);
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let (fx, fy) = (x as f64 / width as f64, y as f64 / height as f64);
|
||||||
|
let disc = ((fx - 0.3).powi(2) + (fy - 0.6).powi(2)).sqrt() < 0.2;
|
||||||
|
let v = if disc { 240.0 } else { 40.0 + 120.0 * fx * fy };
|
||||||
|
luma.push(v as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
luma
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resized_copy_hashes_alike() {
|
||||||
|
let big = difference_hash(900, 800, &picture(900, 800)).unwrap();
|
||||||
|
let small = difference_hash(91, 79, &picture(91, 79)).unwrap();
|
||||||
|
assert!(
|
||||||
|
alike(big, small),
|
||||||
|
"{} bits apart",
|
||||||
|
(big ^ small).count_ones()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_different_picture_hashes_apart() {
|
||||||
|
let one = difference_hash(90, 80, &picture(90, 80)).unwrap();
|
||||||
|
let mirrored: Vec<u8> = picture(90, 80)
|
||||||
|
.chunks(90)
|
||||||
|
.flat_map(|row| row.iter().rev().copied().collect::<Vec<_>>())
|
||||||
|
.collect();
|
||||||
|
let other = difference_hash(90, 80, &mirrored).unwrap();
|
||||||
|
assert!(!alike(one, other));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flat_picture_has_no_hash() {
|
||||||
|
assert_eq!(difference_hash(64, 64, &[17; 64 * 64]), None);
|
||||||
|
assert_eq!(difference_hash(0, 0, &[]), None);
|
||||||
|
assert_eq!(difference_hash(4, 4, &[0; 3]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_smaller_than_the_grid_still_hashes() {
|
||||||
|
let luma: Vec<u8> = (0..4 * 3).map(|i| (i * 20) as u8).collect();
|
||||||
|
assert!(difference_hash(4, 3, &luma).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alike_hashes_form_sets_and_catch_on() {
|
||||||
|
let a = 0x0123_4567_89ab_cdef_u64;
|
||||||
|
let hashes = [
|
||||||
|
a,
|
||||||
|
!a,
|
||||||
|
a ^ 0b111,
|
||||||
|
a ^ 0b111 ^ (0b1111 << 40),
|
||||||
|
0x5555_0000_ffff_aaaa,
|
||||||
|
];
|
||||||
|
assert_eq!(sets(&hashes), vec![vec![0, 2, 3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_apart_in_most_bytes_are_still_found() {
|
||||||
|
// One bit apart in each of six bytes: six bits in all, and only the
|
||||||
|
// top two bytes agree.
|
||||||
|
let a = 1u64 << 63;
|
||||||
|
let b = (0..6).fold(a, |h, byte| h | 1 << (byte * 8));
|
||||||
|
assert_eq!(sets(&[a, b]), vec![vec![0, 1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -175,6 +175,40 @@ impl Core {
|
|||||||
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make sure every album of ours has its contents on disk, for a filter
|
||||||
|
/// that asks which photos no album holds. Contents are otherwise fetched
|
||||||
|
/// the first time an album is opened, so an album never opened would read
|
||||||
|
/// as empty and its photos as unfiled.
|
||||||
|
///
|
||||||
|
/// Shared-with-me albums are skipped: their photos live on the sharer's
|
||||||
|
/// volume and are never in our timeline. The freshness stamps are no guide
|
||||||
|
/// here — any album change clears all of them — so only an album whose
|
||||||
|
/// server count disagrees with an empty local copy is waited for.
|
||||||
|
pub(crate) fn ensure_album_contents(&self) -> CoreResult<()> {
|
||||||
|
if self.db.albums_count().map_err(CoreError::from)? == 0
|
||||||
|
&& !self.rt.block_on(self.refresh_albums())?
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let albums = self.db.albums_list().map_err(CoreError::from)?;
|
||||||
|
for album in albums.iter().filter(|a| !a.shared && a.photo_count > 0) {
|
||||||
|
let stored = self
|
||||||
|
.db
|
||||||
|
.album_photos_count(&album.uid)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
if stored > 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(uid) = parse_uid(&album.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Err(error) = self.rt.block_on(self.refresh_album(&uid)) {
|
||||||
|
warn!(album = %album.uid, %error, "fetching an album's contents failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-enumerate one album's photos and replace its persisted contents.
|
/// Re-enumerate one album's photos and replace its persisted contents.
|
||||||
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||||
let photos = self.photos();
|
let photos = self.photos();
|
||||||
|
|||||||
@@ -14,15 +14,38 @@
|
|||||||
//! that is open, being written, or still owed an upload, for the same reason the
|
//! that is open, being written, or still owed an upload, for the same reason the
|
||||||
//! sweep does: the node is about to change under the decision.
|
//! sweep does: the node is about to change under the decision.
|
||||||
//!
|
//!
|
||||||
//! Only copies under My Files are handled here. A copy inside a mirror folder is
|
//! Copies under My Files are named by their mountpoint-relative path. Copies
|
||||||
//! a local file first; the sync engine owns it.
|
//! inside a synced folder are named by their absolute local path, so a resolve
|
||||||
|
//! request routes to the on-demand mount that serves them. A copy inside a
|
||||||
|
//! mirror folder is listed too, but it is a local file first: the sync engine
|
||||||
|
//! owns it, so it is resolved by editing the folder, not through here.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use pdfs_core::control::{ConflictInfo, ConflictKeep};
|
use pdfs_core::control::{ConflictInfo, ConflictKeep};
|
||||||
use sweep::conflict_base_name;
|
use sweep::conflict_base_name;
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
/// Every conflict copy under My Files, with the file it is a copy of.
|
/// Where the user finds a conflict copy: its mountpoint-relative path under
|
||||||
|
/// My Files, or its absolute local path inside a synced folder. `None` for a
|
||||||
|
/// node no location of this machine shows, such as one in another
|
||||||
|
/// computer's device tree.
|
||||||
|
///
|
||||||
|
/// The sweep only flags what this can name, and the listing uses it too. A
|
||||||
|
/// flagged copy the listing cannot see reads as resolved in the activity
|
||||||
|
/// feed while it still sits in the folder (`docs/BUGS.md` B99).
|
||||||
|
pub(crate) fn conflict_path(&self, roots: &SearchRoots, uid: &NodeUid) -> Option<String> {
|
||||||
|
let uid = uid.to_string();
|
||||||
|
if let Ok(Some(path)) = self
|
||||||
|
.db
|
||||||
|
.path_relative_to(&self.primary_root_uid.to_string(), &uid)
|
||||||
|
{
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
let drive_path = self.db.node_path(&uid).ok().flatten()?;
|
||||||
|
roots.resolve(&drive_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every conflict copy this machine shows, with the file it is a copy of.
|
||||||
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
|
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
|
||||||
let stored = self
|
let stored = self
|
||||||
.db
|
.db
|
||||||
@@ -37,13 +60,13 @@ impl Core {
|
|||||||
for node in &nodes {
|
for node in &nodes {
|
||||||
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
|
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
|
||||||
}
|
}
|
||||||
let root = self.primary_root_uid.to_string();
|
let roots = self.search_roots();
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for node in &nodes {
|
for node in &nodes {
|
||||||
let Some(base) = conflict_base_name(&node.name) else {
|
let Some(base) = conflict_base_name(&node.name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Ok(Some(path)) = self.db.path_relative_to(&root, &node.uid.to_string()) else {
|
let Some(path) = self.conflict_path(&roots, &node.uid) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let original = by_parent_name
|
let original = by_parent_name
|
||||||
|
|||||||
@@ -185,6 +185,38 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
|||||||
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`route_to_mount`] for a conflict copy. The listing names a copy inside a
|
||||||
|
/// synced folder by its absolute local path (`docs/BUGS.md` B99), so the
|
||||||
|
/// resolve has to run against the on-demand mount serving it. A mirror folder
|
||||||
|
/// has no mount to run against: its copy is an ordinary local file.
|
||||||
|
fn route_conflict(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
|
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
||||||
|
let p = Path::new(path);
|
||||||
|
let mirrored = core
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||||
|
if mirrored {
|
||||||
|
CoreError::invalid(format!(
|
||||||
|
"{path} is in a mirrored folder; rename or delete the local file there"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if routed
|
||||||
|
.1
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
Ok(routed)
|
||||||
|
} else {
|
||||||
|
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||||
/// the mount root.
|
/// the mount root.
|
||||||
@@ -408,7 +440,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
kind,
|
kind,
|
||||||
range,
|
range,
|
||||||
favorites,
|
favorites,
|
||||||
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
|
not_in_album,
|
||||||
|
}) => match core.photos_timeline(
|
||||||
|
offset,
|
||||||
|
clamp_limit(limit),
|
||||||
|
kind,
|
||||||
|
range,
|
||||||
|
favorites,
|
||||||
|
not_in_album,
|
||||||
|
) {
|
||||||
Ok(Some(items)) => CtlResponse::Photos {
|
Ok(Some(items)) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
items,
|
items,
|
||||||
@@ -421,6 +461,49 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
|
||||||
|
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotoSimilar) => match core.photo_similar() {
|
||||||
|
Ok((sets, hashing, uncompared)) => CtlResponse::PhotoSimilar {
|
||||||
|
sets,
|
||||||
|
hashing,
|
||||||
|
uncompared,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
|
||||||
|
Ok(items) => CtlResponse::Places {
|
||||||
|
items,
|
||||||
|
mapping: core.timeline_progress(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PlacePhotos { id, offset, limit }) => {
|
||||||
|
match core.place_photos(id, offset, clamp_limit(limit)) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::PlaceLocations { id }) => match core.place_locations(id) {
|
||||||
|
Ok(items) => CtlResponse::PhotoLocations { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
|
||||||
|
match core.photos_on_this_day(clamp_limit(limit)) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
|
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
|
||||||
Ok(items) => CtlResponse::Photos {
|
Ok(items) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
@@ -514,8 +597,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
items: core.photo_thumbs(&parsed),
|
items: core.photo_thumbs(&parsed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
|
Ok(CtlRequest::TrashNodes { uids, files_only }) => match Core::parse_uids(&uids) {
|
||||||
Ok(parsed) => match core.trash_photos(&parsed) {
|
Ok(parsed) => match core.trash_photos(&parsed, files_only) {
|
||||||
Ok((trashed, failed)) => CtlResponse::Trashed {
|
Ok((trashed, failed)) => CtlResponse::Trashed {
|
||||||
trashed,
|
trashed,
|
||||||
failed: failed
|
failed: failed
|
||||||
@@ -1123,13 +1206,15 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(items) => CtlResponse::Conflicts { items },
|
Ok(items) => CtlResponse::Conflicts { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ResolveConflict { path, keep }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ResolveConflict { path, keep }) => {
|
||||||
Ok(rel) => match core.resolve_conflict(&rel, &keep) {
|
match route_conflict(core, mountpoint, &path) {
|
||||||
Ok(message) => CtlResponse::Ok { message },
|
Ok((core, rel)) => match core.resolve_conflict(&rel, &keep) {
|
||||||
|
Ok(message) => CtlResponse::Ok { message },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
}
|
||||||
Err(e) => CtlResponse::error(e),
|
}
|
||||||
},
|
|
||||||
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
||||||
Ok(items) => CtlResponse::PendingOps { items },
|
Ok(items) => CtlResponse::PendingOps { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
|
|||||||
@@ -550,17 +550,25 @@ struct Core {
|
|||||||
/// already running, so a burst of page requests against a stale listing kicks
|
/// already running, so a burst of page requests against a stale listing kicks
|
||||||
/// off one refresh rather than one per request.
|
/// off one refresh rather than one per request.
|
||||||
timeline_refreshing: Arc<AtomicBool>,
|
timeline_refreshing: Arc<AtomicBool>,
|
||||||
|
/// How far the running timeline refresh has got resolving photo metadata,
|
||||||
|
/// as `(done, total)` photos; `None` when no refresh is resolving any. The
|
||||||
|
/// Places view shows it while a library's locations are first read.
|
||||||
|
timeline_progress: Arc<Mutex<Option<(usize, usize)>>>,
|
||||||
/// The same, for the album listing.
|
/// The same, for the album listing.
|
||||||
albums_refreshing: Arc<AtomicBool>,
|
albums_refreshing: Arc<AtomicBool>,
|
||||||
trash_refreshing: Arc<AtomicBool>,
|
trash_refreshing: Arc<AtomicBool>,
|
||||||
|
/// True while the similar-photo pass hashes thumbnails (see
|
||||||
|
/// [`Core::photo_similar`]), so asking again doesn't start a second one.
|
||||||
|
similar_hashing: Arc<AtomicBool>,
|
||||||
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
||||||
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
||||||
/// instead of polling or blocking for the whole run. See
|
/// instead of polling or blocking for the whole run. See
|
||||||
/// [`Core::await_trash_refresh`].
|
/// [`Core::await_trash_refresh`].
|
||||||
trash_progress: Arc<tokio::sync::Notify>,
|
trash_progress: Arc<tokio::sync::Notify>,
|
||||||
/// Conflict copies the sweep has already flagged as needing attention this
|
/// Conflict copies the sweep has already checked against the activity log
|
||||||
/// run, so a divergent `(sync-conflict …)` file is logged once rather than
|
/// this run, so each pass does not ask the database again. The log itself
|
||||||
/// on every sweep pass. See [`Core::run_conflict_sweep_loop`].
|
/// is what keeps a copy from being flagged again after a restart. See
|
||||||
|
/// [`Core::run_conflict_sweep_loop`] and `docs/BUGS.md` B99.
|
||||||
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
|
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
|
||||||
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
||||||
/// only report them. Resolved once at mount from config + environment
|
/// only report them. Resolved once at mount from config + environment
|
||||||
|
|||||||
@@ -391,6 +391,8 @@ pub fn mount(
|
|||||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
|
timeline_progress: Arc::new(Mutex::new(None)),
|
||||||
|
similar_hashing: Arc::new(AtomicBool::new(false)),
|
||||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
FileThumbRequest, PhotoItem, PhotoKind, PhotoThumb, ThumbnailBuildStatus, is_raw_image_name,
|
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
|
||||||
is_thumbnail_image_name,
|
ThumbnailBuildStatus, is_raw_image_name, is_thumbnail_image_name,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{self, StoredPhoto};
|
use pdfs_core::db::{self, StoredPhoto};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
@@ -43,6 +43,8 @@ const THUMB_QUALITY: u8 = 82;
|
|||||||
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
|
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
|
||||||
/// saturate the link and starve the rest of the daemon.
|
/// saturate the link and starve the rest of the daemon.
|
||||||
pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4;
|
pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4;
|
||||||
|
/// Shots the similar-photo pass fetches thumbnails for at a time.
|
||||||
|
const SIMILAR_CHUNK: usize = 64;
|
||||||
/// Images processed between recursive-build progress updates.
|
/// Images processed between recursive-build progress updates.
|
||||||
const THUMB_BUILD_CHUNK: usize = 16;
|
const THUMB_BUILD_CHUNK: usize = 16;
|
||||||
/// A recursive build may wait briefly for an opportunistic tile job that
|
/// A recursive build may wait briefly for an opportunistic tile job that
|
||||||
@@ -81,6 +83,46 @@ fn ratio_of(bytes: &[u8]) -> Option<f64> {
|
|||||||
(height > 0).then(|| f64::from(width) / f64::from(height))
|
(height > 0).then(|| f64::from(width) / f64::from(height))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The difference hash of each cached thumbnail in `paths`, keyed like the
|
||||||
|
/// input, or [`pdfs_core::similar::NO_HASH`] for one that can't be decoded or
|
||||||
|
/// shows no picture. Decoding is spread over the machine's cores.
|
||||||
|
fn similar_hashes(paths: Vec<(String, PathBuf)>) -> Vec<(String, u64)> {
|
||||||
|
fn hash(path: &Path) -> u64 {
|
||||||
|
image::ImageReader::open(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|reader| reader.with_guessed_format().ok())
|
||||||
|
.and_then(|reader| reader.decode().ok())
|
||||||
|
.and_then(|image| {
|
||||||
|
let luma = image.to_luma8();
|
||||||
|
pdfs_core::similar::difference_hash(
|
||||||
|
luma.width() as usize,
|
||||||
|
luma.height() as usize,
|
||||||
|
luma.as_raw(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(pdfs_core::similar::NO_HASH)
|
||||||
|
}
|
||||||
|
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||||
|
let per_thread = paths.len().div_ceil(threads).max(1);
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let workers: Vec<_> = paths
|
||||||
|
.chunks(per_thread)
|
||||||
|
.map(|chunk| {
|
||||||
|
scope.spawn(move || {
|
||||||
|
chunk
|
||||||
|
.iter()
|
||||||
|
.map(|(uid, path)| (uid.clone(), hash(path)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
workers
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|worker| worker.join().unwrap_or_default())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
|
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
|
||||||
/// made from (free at that point — the full image had to be decoded anyway).
|
/// made from (free at that point — the full image had to be decoded anyway).
|
||||||
struct GeneratedThumb {
|
struct GeneratedThumb {
|
||||||
@@ -601,6 +643,16 @@ fn extract_raw_preview(
|
|||||||
pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>);
|
pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>);
|
||||||
|
|
||||||
/// Formats supported by the local thumbnail decoder and exposed by the GUI.
|
/// Formats supported by the local thumbnail decoder and exposed by the GUI.
|
||||||
|
/// Clears the timeline refresh's progress when dropped, so an error or a
|
||||||
|
/// cancelled refresh doesn't leave a bar standing.
|
||||||
|
struct ProgressGuard<'a>(&'a parking_lot::Mutex<Option<(usize, usize)>>);
|
||||||
|
|
||||||
|
impl Drop for ProgressGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
*self.0.lock() = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a timeline enrichment pass resolves for one photo, beyond what the
|
/// What a timeline enrichment pass resolves for one photo, beyond what the
|
||||||
/// timeline listing already carries.
|
/// timeline listing already carries.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -609,6 +661,8 @@ struct PhotoMeta {
|
|||||||
media_type: Option<String>,
|
media_type: Option<String>,
|
||||||
favorite: bool,
|
favorite: bool,
|
||||||
content_hash: Option<String>,
|
content_hash: Option<String>,
|
||||||
|
/// Where the photo was taken, as its uploader recorded it.
|
||||||
|
location: Option<(f64, f64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
@@ -637,6 +691,7 @@ impl Core {
|
|||||||
kind: Option<PhotoKind>,
|
kind: Option<PhotoKind>,
|
||||||
range: Option<(i64, i64)>,
|
range: Option<(i64, i64)>,
|
||||||
favorites: bool,
|
favorites: bool,
|
||||||
|
not_in_album: bool,
|
||||||
) -> CoreResult<Option<Vec<PhotoItem>>> {
|
) -> CoreResult<Option<Vec<PhotoItem>>> {
|
||||||
let count = self.db.photos_count().map_err(CoreError::from)?;
|
let count = self.db.photos_count().map_err(CoreError::from)?;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
@@ -653,10 +708,16 @@ impl Core {
|
|||||||
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||||||
self.spawn_timeline_refresh();
|
self.spawn_timeline_refresh();
|
||||||
}
|
}
|
||||||
|
// The filter is only as good as the album contents on disk, and those
|
||||||
|
// are fetched lazily, per album opened. Fill in the missing ones before
|
||||||
|
// the first page; later pages reuse what that fetch stored.
|
||||||
|
if not_in_album && offset == 0 {
|
||||||
|
self.ensure_album_contents()?;
|
||||||
|
}
|
||||||
|
|
||||||
let page = self
|
let page = self
|
||||||
.db
|
.db
|
||||||
.photos_page(offset, limit, kind, range, favorites)
|
.photos_page(offset, limit, kind, range, favorites, not_in_album)
|
||||||
.map_err(CoreError::from)?;
|
.map_err(CoreError::from)?;
|
||||||
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
|
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
|
||||||
}
|
}
|
||||||
@@ -722,6 +783,159 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The timeline's duplicate sets, as wire items (see
|
||||||
|
/// [`db::Db::photo_duplicates`]).
|
||||||
|
pub(crate) fn photo_duplicates(&self) -> CoreResult<Vec<Vec<PhotoItem>>> {
|
||||||
|
let sets = self.db.photo_duplicates().map_err(CoreError::from)?;
|
||||||
|
Ok(sets
|
||||||
|
.into_iter()
|
||||||
|
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sets of shots that look alike, as wire items (see
|
||||||
|
/// [`db::Db::photo_similar`]), how far hashing has got while it runs, and
|
||||||
|
/// how many shots couldn't be compared once it's done. Starts a hashing
|
||||||
|
/// pass for the shots not hashed yet unless one is running.
|
||||||
|
pub(crate) fn photo_similar(
|
||||||
|
&self,
|
||||||
|
) -> CoreResult<(Vec<Vec<PhotoItem>>, Option<MappingProgress>, usize)> {
|
||||||
|
self.spawn_similar_hashing();
|
||||||
|
let sets = self.db.photo_similar().map_err(CoreError::from)?;
|
||||||
|
let (done, total) = self.db.similar_progress().map_err(CoreError::from)?;
|
||||||
|
let hashing = self
|
||||||
|
.similar_hashing
|
||||||
|
.load(Ordering::SeqCst)
|
||||||
|
.then_some(MappingProgress { done, total });
|
||||||
|
let uncompared = if hashing.is_some() { 0 } else { total - done };
|
||||||
|
let sets = sets
|
||||||
|
.into_iter()
|
||||||
|
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
|
||||||
|
.collect();
|
||||||
|
Ok((sets, hashing, uncompared))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_similar_hashing(&self) {
|
||||||
|
if self.similar_hashing.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let core = self.clone();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-similar".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(e) = core.hash_similar() {
|
||||||
|
warn!(error = %e, "similar-photo hashing failed");
|
||||||
|
}
|
||||||
|
core.similar_hashing.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
warn!(error = %e, "could not start similar-photo hashing");
|
||||||
|
self.similar_hashing.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash the look of every shot not hashed yet, a chunk at a time. Only
|
||||||
|
/// thumbnails the server has are used: a photo without one is skipped
|
||||||
|
/// rather than downloaded whole, and tried again on the next pass.
|
||||||
|
fn hash_similar(&self) -> CoreResult<()> {
|
||||||
|
let mut tried: HashSet<String> = HashSet::new();
|
||||||
|
loop {
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Shots tried this pass without a thumbnail are still unhashed and
|
||||||
|
// come back first, so ask past them.
|
||||||
|
let chunk: Vec<NodeUid> = self
|
||||||
|
.db
|
||||||
|
.photos_unhashed(tried.len() + SIMILAR_CHUNK)
|
||||||
|
.map_err(CoreError::from)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|photo| tried.insert(photo.uid.clone()))
|
||||||
|
.take(SIMILAR_CHUNK)
|
||||||
|
.filter_map(|photo| parse_uid(&photo.uid))
|
||||||
|
.collect();
|
||||||
|
if chunk.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let paths: Vec<(String, PathBuf)> = self
|
||||||
|
.photo_thumbs_with(&chunk, false)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|thumb| Some((thumb.uid, PathBuf::from(thumb.path?))))
|
||||||
|
.collect();
|
||||||
|
for (uid, hash) in similar_hashes(paths) {
|
||||||
|
self.db
|
||||||
|
.photo_set_similar_hash(&uid, hash)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The towns the photos were taken in, as wire items. A town the bundled
|
||||||
|
/// table no longer knows (the stored id came from an older table) is left
|
||||||
|
/// out rather than shown without a name.
|
||||||
|
pub(crate) fn photo_places(&self) -> CoreResult<Vec<PlaceInfo>> {
|
||||||
|
let places = self.db.photo_places().map_err(CoreError::from)?;
|
||||||
|
Ok(places
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|place| {
|
||||||
|
let city = pdfs_core::places::city(place.id)?;
|
||||||
|
Some(PlaceInfo {
|
||||||
|
id: place.id,
|
||||||
|
name: city.name.to_string(),
|
||||||
|
country: city.country.to_string(),
|
||||||
|
latitude: city.latitude,
|
||||||
|
longitude: city.longitude,
|
||||||
|
photo_count: place.count,
|
||||||
|
cover: self.photo_item(place.cover),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page of the photos taken in town `id`, as wire items.
|
||||||
|
pub(crate) fn place_photos(
|
||||||
|
&self,
|
||||||
|
id: u32,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> CoreResult<Vec<PhotoItem>> {
|
||||||
|
let photos = self
|
||||||
|
.db
|
||||||
|
.photos_at_place(id, offset, limit)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every photo taken in town `id` with its own location, as wire items.
|
||||||
|
pub(crate) fn place_locations(&self, id: u32) -> CoreResult<Vec<PhotoLocation>> {
|
||||||
|
let photos = self
|
||||||
|
.db
|
||||||
|
.photo_locations_at_place(id)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
Ok(photos
|
||||||
|
.into_iter()
|
||||||
|
.map(|(photo, (latitude, longitude))| PhotoLocation {
|
||||||
|
photo: self.photo_item(photo),
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Photos taken on today's date in earlier years, as wire items. Served
|
||||||
|
/// from the stored timeline only: the strip is a nicety, not worth a wait
|
||||||
|
/// on the network, and the timeline's own load keeps that store fresh.
|
||||||
|
pub(crate) fn photos_on_this_day(&self, limit: usize) -> CoreResult<Vec<PhotoItem>> {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_secs() as i64);
|
||||||
|
let photos = self
|
||||||
|
.db
|
||||||
|
.photos_on_this_day(now, limit)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
Ok(photos.into_iter().map(|p| self.photo_item(p)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Every file of one photo's group, in server order, as wire items. A photo
|
/// Every file of one photo's group, in server order, as wire items. A photo
|
||||||
/// the timeline does not hold comes back empty, which the lightbox reads as
|
/// the timeline does not hold comes back empty, which the lightbox reads as
|
||||||
/// "nothing to switch between".
|
/// "nothing to switch between".
|
||||||
@@ -801,12 +1015,23 @@ impl Core {
|
|||||||
/// A photo that is one file of a group takes its whole group with it. The
|
/// A photo that is one file of a group takes its whole group with it. The
|
||||||
/// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to
|
/// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to
|
||||||
/// delete the shot — leaving the RAW behind would put the photo back on the
|
/// delete the shot — leaving the RAW behind would put the photo back on the
|
||||||
/// Raw tab and nowhere else.
|
/// Raw tab and nowhere else. `files_only` turns that off, for removing
|
||||||
pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult<TrashOutcome> {
|
/// one copy of a shot that is stored twice.
|
||||||
|
pub(crate) fn trash_photos(
|
||||||
|
&self,
|
||||||
|
uids: &[NodeUid],
|
||||||
|
files_only: bool,
|
||||||
|
) -> CoreResult<TrashOutcome> {
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok((Vec::new(), Vec::new()));
|
return Ok((Vec::new(), Vec::new()));
|
||||||
}
|
}
|
||||||
let uids = &self.expand_photo_groups(uids);
|
let expanded;
|
||||||
|
let uids = if files_only {
|
||||||
|
uids
|
||||||
|
} else {
|
||||||
|
expanded = self.expand_photo_groups(uids);
|
||||||
|
&expanded
|
||||||
|
};
|
||||||
for uid in uids {
|
for uid in uids {
|
||||||
self.require_photo_writable(uid)
|
self.require_photo_writable(uid)
|
||||||
.map_err(|errno| self.errno_error(errno, "trash access"))?;
|
.map_err(|errno| self.errno_error(errno, "trash access"))?;
|
||||||
@@ -855,6 +1080,13 @@ impl Core {
|
|||||||
/// milliseconds where the raw costs a full download and a raw decode. The
|
/// milliseconds where the raw costs a full download and a raw decode. The
|
||||||
/// tile still answers under the uid that was asked for.
|
/// tile still answers under the uid that was asked for.
|
||||||
pub(crate) fn photo_thumbs(&self, requested: &[NodeUid]) -> Vec<PhotoThumb> {
|
pub(crate) fn photo_thumbs(&self, requested: &[NodeUid]) -> Vec<PhotoThumb> {
|
||||||
|
self.photo_thumbs_with(requested, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::photo_thumbs`], with local generation only when `generate` is
|
||||||
|
/// set. Without it, a photo the server has no thumbnail for is left
|
||||||
|
/// without one rather than downloaded whole.
|
||||||
|
fn photo_thumbs_with(&self, requested: &[NodeUid], generate: bool) -> Vec<PhotoThumb> {
|
||||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||||
// Only a raw can be stood in for, and that is one batched read; the
|
// Only a raw can be stood in for, and that is one batched read; the
|
||||||
// per-shot group lookup is then paid for raws alone, not for a page of
|
// per-shot group lookup is then paid for raws alone, not for a page of
|
||||||
@@ -955,7 +1187,7 @@ impl Core {
|
|||||||
// that never generated one. Off the request path: a full-size photo
|
// that never generated one. Off the request path: a full-size photo
|
||||||
// takes far longer to fetch than the whole rest of the batch, and the
|
// takes far longer to fetch than the whole rest of the batch, and the
|
||||||
// thumbnails that *are* ready must not wait behind it.
|
// thumbnails that *are* ready must not wait behind it.
|
||||||
if !missing.is_empty() {
|
if generate && !missing.is_empty() {
|
||||||
self.spawn_generate_thumbs(missing, &tags, &names, ThumbJob::Photos);
|
self.spawn_generate_thumbs(missing, &tags, &names, ThumbJob::Photos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1690,7 +1922,14 @@ impl Core {
|
|||||||
// can land in different chunks, and one of them may not resolve at all.
|
// can land in different chunks, and one of them may not resolve at all.
|
||||||
let mut main_of: HashMap<String, String> = HashMap::new();
|
let mut main_of: HashMap<String, String> = HashMap::new();
|
||||||
let mut stopped = false;
|
let mut stopped = false;
|
||||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
// Photos read before count as done, so the bar picks up where an
|
||||||
|
// interrupted pass left off. Cleared however the pass ends.
|
||||||
|
let skipped = items.len() - uids.len();
|
||||||
|
let progress = ProgressGuard(&self.timeline_progress);
|
||||||
|
if !uids.is_empty() {
|
||||||
|
*progress.0.lock() = Some((skipped, items.len()));
|
||||||
|
}
|
||||||
|
for (index, chunk) in uids.chunks(TIMELINE_ENRICH_CHUNK).enumerate() {
|
||||||
// Teardown cancels the runtime under this loop, and every chunk
|
// Teardown cancels the runtime under this loop, and every chunk
|
||||||
// still in flight then fails its decrypt task — a burst of
|
// still in flight then fails its decrypt task — a burst of
|
||||||
// "skipping undecryptable photo ... task was cancelled" warnings
|
// "skipping undecryptable photo ... task was cancelled" warnings
|
||||||
@@ -1717,6 +1956,11 @@ impl Core {
|
|||||||
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
|
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
|
||||||
let key = node.uid.to_string();
|
let key = node.uid.to_string();
|
||||||
let content_hash = node.photo.as_ref().and_then(|p| p.content_hash.clone());
|
let content_hash = node.photo.as_ref().and_then(|p| p.content_hash.clone());
|
||||||
|
let location = node
|
||||||
|
.photo
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.location)
|
||||||
|
.map(|l| (l.latitude, l.longitude));
|
||||||
if let Some(photo) = node.photo.as_ref() {
|
if let Some(photo) = node.photo.as_ref() {
|
||||||
if let Some(main) = photo.main_photo_uid.as_ref() {
|
if let Some(main) = photo.main_photo_uid.as_ref() {
|
||||||
main_of.insert(key.clone(), main.to_string());
|
main_of.insert(key.clone(), main.to_string());
|
||||||
@@ -1732,13 +1976,17 @@ impl Core {
|
|||||||
media_type,
|
media_type,
|
||||||
favorite,
|
favorite,
|
||||||
content_hash,
|
content_hash,
|
||||||
|
location,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
|
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
|
||||||
}
|
}
|
||||||
|
let done = (skipped + (index + 1) * TIMELINE_ENRICH_CHUNK).min(items.len());
|
||||||
|
*progress.0.lock() = Some((done, items.len()));
|
||||||
}
|
}
|
||||||
|
drop(progress);
|
||||||
|
|
||||||
let rows: Vec<db::TimelineRow> = items
|
let rows: Vec<db::TimelineRow> = items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1753,6 +2001,7 @@ impl Core {
|
|||||||
media_type: m.media_type,
|
media_type: m.media_type,
|
||||||
favorite: Some(m.favorite),
|
favorite: Some(m.favorite),
|
||||||
content_hash: m.content_hash,
|
content_hash: m.content_hash,
|
||||||
|
location: m.location,
|
||||||
main_uid,
|
main_uid,
|
||||||
resolved_at: Some(resolved_at),
|
resolved_at: Some(resolved_at),
|
||||||
..db::TimelineRow::new(key, it.capture_time)
|
..db::TimelineRow::new(key, it.capture_time)
|
||||||
@@ -1799,6 +2048,14 @@ impl Core {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How far the running timeline refresh has got reading photo metadata, or
|
||||||
|
/// `None` when no refresh is reading any.
|
||||||
|
pub(crate) fn timeline_progress(&self) -> Option<MappingProgress> {
|
||||||
|
self.timeline_progress
|
||||||
|
.lock()
|
||||||
|
.map(|(done, total)| MappingProgress { done, total })
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a timeline refresh is running. A front-end that asked for one
|
/// Whether a timeline refresh is running. A front-end that asked for one
|
||||||
/// watches this to know when the new timeline is worth re-reading.
|
/// watches this to know when the new timeline is worth re-reading.
|
||||||
pub(crate) fn timeline_refreshing(&self) -> bool {
|
pub(crate) fn timeline_refreshing(&self) -> bool {
|
||||||
@@ -1833,10 +2090,11 @@ mod thumb_tests {
|
|||||||
use super::{
|
use super::{
|
||||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
||||||
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
||||||
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
similar_hashes, thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
||||||
};
|
};
|
||||||
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
||||||
use proton_drive_rs::ThumbnailType;
|
use proton_drive_rs::ThumbnailType;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -2110,6 +2368,46 @@ mod thumb_tests {
|
|||||||
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
|
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thumbnails_hash_alike_across_sizes_and_undecodable_ones_get_no_hash() {
|
||||||
|
let tmp = scratch("similar-hashes");
|
||||||
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
// One picture at two sizes: a diagonal gradient with a bright corner.
|
||||||
|
let picture = |width: u32, height: u32| {
|
||||||
|
let image = image::GrayImage::from_fn(width, height, |x, y| {
|
||||||
|
let (fx, fy) = (x * 4 / width, y * 4 / height);
|
||||||
|
image::Luma([if fx + fy == 0 {
|
||||||
|
250
|
||||||
|
} else {
|
||||||
|
(fx * 40 + fy * 20) as u8
|
||||||
|
}])
|
||||||
|
});
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
image::codecs::jpeg::JpegEncoder::new(&mut bytes)
|
||||||
|
.encode_image(&image)
|
||||||
|
.unwrap();
|
||||||
|
bytes
|
||||||
|
};
|
||||||
|
std::fs::write(tmp.join("big"), picture(512, 384)).unwrap();
|
||||||
|
std::fs::write(tmp.join("small"), picture(256, 192)).unwrap();
|
||||||
|
std::fs::write(tmp.join("torn"), b"not a picture").unwrap();
|
||||||
|
|
||||||
|
let hashes: HashMap<String, u64> = similar_hashes(
|
||||||
|
["big", "small", "torn", "gone"]
|
||||||
|
.iter()
|
||||||
|
.map(|name| (name.to_string(), tmp.join(name)))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
assert_eq!(hashes.len(), 4);
|
||||||
|
assert_ne!(hashes["big"], pdfs_core::similar::NO_HASH);
|
||||||
|
assert!(pdfs_core::similar::alike(hashes["big"], hashes["small"]));
|
||||||
|
assert_eq!(hashes["torn"], pdfs_core::similar::NO_HASH);
|
||||||
|
assert_eq!(hashes["gone"], pdfs_core::similar::NO_HASH);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_uploaded_photo_carries_its_thumbnail() {
|
fn an_uploaded_photo_carries_its_thumbnail() {
|
||||||
let tmp = scratch("upload-photo");
|
let tmp = scratch("upload-photo");
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ impl Core {
|
|||||||
by_parent_name.insert((parent, node.name.as_str()), node);
|
by_parent_name.insert((parent, node.name.as_str()), node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let roots = self.search_roots();
|
||||||
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
for node in &nodes {
|
for node in &nodes {
|
||||||
if node.trashed || node.is_folder() {
|
if node.trashed || node.is_folder() {
|
||||||
@@ -132,7 +133,16 @@ impl Core {
|
|||||||
self.remove_conflict_copy(node, &base);
|
self.remove_conflict_copy(node, &base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
|
// Only flag what the conflict listing can show. Anything else
|
||||||
|
// would sit in the feed as "resolved" with no way to act on
|
||||||
|
// it (`docs/BUGS.md` B99).
|
||||||
|
SweepAction::Flag(detail) => {
|
||||||
|
if self.conflict_path(&roots, &node.uid).is_some() {
|
||||||
|
self.flag_conflict(&node.uid, &node.name, detail);
|
||||||
|
} else {
|
||||||
|
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,12 +283,21 @@ impl Core {
|
|||||||
busy
|
busy
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
/// Surface a conflict copy that needs the user's attention, once. The
|
||||||
/// daemon run so the activity feed is not spammed each pass.
|
/// in-memory set spares the database each pass; the activity log itself is
|
||||||
|
/// what keeps a restart from logging the same copy again (`docs/BUGS.md`
|
||||||
|
/// B99). A copy's name carries its own timestamp, so the name identifies it.
|
||||||
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||||
if !self.conflict_notified.lock().insert(uid.clone()) {
|
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
match self.db.activity_has(ActivityKind::Conflict, name) {
|
||||||
|
Ok(false) => {}
|
||||||
|
Ok(true) => return,
|
||||||
|
// Cannot tell: a repeat entry is harmless, a missing one hides a
|
||||||
|
// conflict.
|
||||||
|
Err(e) => warn!(%uid, name, error = ?e, "conflict sweep: activity check failed"),
|
||||||
|
}
|
||||||
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||||
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,9 @@ adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
|||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
exif = { version = "0.6", package = "kamadak-exif" }
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
webkit6 = "0.6"
|
webkit6 = "0.6"
|
||||||
|
# Town and street names on the Places map, with font fallback for every script.
|
||||||
|
pangocairo = "0.22"
|
||||||
|
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
||||||
# The system libintl (glibc) rather than a bundled gettext build.
|
# The system libintl (glibc) rather than a bundled gettext build.
|
||||||
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
||||||
@@ -26,4 +26,6 @@ fn main() {
|
|||||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||||
println!("cargo:rerun-if-changed=resources/icons");
|
println!("cargo:rerun-if-changed=resources/icons");
|
||||||
println!("cargo:rerun-if-changed=resources/style.css");
|
println!("cargo:rerun-if-changed=resources/style.css");
|
||||||
|
println!("cargo:rerun-if-changed=resources/proton-theme.css");
|
||||||
|
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
47.98,8.00 48.94,9.45 48.94,11.26
|
||||||
|
47.98,8.00 46.42,6.50 44.94,4.91 43.99,4.95 43.58,4.85 43.13,4.64 42.86,4.32 42.02,4.14 41.88,3.98
|
||||||
|
34.25,31.21 34.53,31.53 34.48,31.58
|
||||||
|
35.55,32.40 35.19,32.53 35.07,32.46 34.96,32.16 34.95,31.84 35.20,31.75 34.95,31.60 34.87,31.40 34.91,31.35 35.10,31.37 35.45,31.48
|
||||||
|
35.11,33.08 35.41,33.08 35.49,33.12 35.53,33.25 35.60,33.24
|
||||||
|
35.79,32.73 35.89,32.94 35.79,33.29 35.84,33.42
|
||||||
|
34.00,35.07 33.87,35.09 33.48,35.00 33.38,35.16 33.19,35.17 32.92,35.09 32.71,35.17
|
||||||
|
77.80,35.50 77.89,35.45 78.04,35.48 78.01,35.25 78.33,34.61 78.67,34.52 78.97,34.30 78.97,34.23 78.73,34.01 78.80,33.50 78.92,33.39
|
||||||
|
77.05,35.11 77.00,34.99 76.59,34.74 76.04,34.67 75.71,34.50 75.32,34.58
|
||||||
|
91.63,27.76 91.98,27.73 92.10,27.81 92.55,27.88 92.69,27.99 92.64,28.06 92.70,28.15 93.17,28.36 93.36,28.65 93.90,28.74 93.97,28.86 94.27,28.97 94.29,29.14 94.62,29.31 94.77,29.18 95.39,29.04 95.52,29.21 95.71,29.24 95.77,29.34 96.13,29.38 96.23,29.25 96.36,29.25 96.18,29.12 96.20,28.96 96.44,29.05 96.58,28.76 96.28,28.41 96.39,28.37 96.60,28.46 96.78,28.37 97.08,28.37 97.32,28.22
|
||||||
|
128.37,38.62 128.17,38.36 128.04,38.31 127.09,38.28 126.67,37.92 126.63,37.78
|
||||||
|
-8.68,27.66 -8.82,27.66 -8.75,27.19 -8.79,27.12 -9.41,27.09 -9.82,26.85 -10.03,26.91 -10.25,26.86 -10.76,27.02 -11.39,26.88 -11.32,26.74 -11.34,26.63 -11.51,26.47 -11.72,26.10 -12.06,25.99 -12.43,24.83 -12.99,24.47 -13.31,23.98 -13.89,23.69 -14.10,23.10 -14.22,22.31 -14.63,21.86 -14.61,21.75 -14.75,21.50 -14.97,21.44 -16.04,21.50 -17.00,21.42
|
||||||
|
35.92,4.62 35.76,4.81 35.80,5.16 35.75,5.34 35.47,5.42 35.33,5.36 35.27,5.49
|
||||||
|
-124.76,48.49 -123.86,48.26 -123.39,48.25 -123.14,48.40 -123.24,48.64 -123.01,48.79 -123.31,48.99
|
||||||
|
91.63,27.76 91.64,27.92 91.27,28.08 91.02,27.97 90.33,28.12
|
||||||
|
23.61,51.52 23.61,51.61 23.71,51.64 23.98,51.59 24.36,51.87 25.79,51.92 27.14,51.75 27.30,51.60 27.69,51.57 27.70,51.48 27.86,51.59 28.18,51.61 28.60,51.54 28.73,51.43 29.10,51.63 29.35,51.38 30.16,51.48 30.33,51.33 30.54,51.27 30.63,51.36 30.53,51.60 30.76,51.90 31.08,52.08 31.76,52.10
|
||||||
|
35.84,33.42 35.60,33.24
|
||||||
|
35.84,33.42 36.03,33.59 35.94,33.67 35.99,33.75 36.09,33.83 36.37,33.84 36.28,33.93 36.58,34.22 36.50,34.43 36.33,34.50 36.43,34.61 36.38,34.66 35.98,34.63
|
||||||
|
34.68,-12.01 34.76,-12.03 34.68,-12.12 34.68,-12.01
|
||||||
|
80.21,42.19 80.26,42.24 80.16,42.63 80.20,42.73 80.54,42.87 80.38,42.97 80.39,43.04 80.62,43.13 80.75,43.10 80.79,43.16 80.67,43.31 80.70,43.43 80.65,43.56 80.36,44.10 80.36,44.55 80.38,44.66 80.48,44.71 80.26,44.81 80.00,44.80 79.87,44.88 80.06,45.01 80.85,45.13 81.69,45.35 81.79,45.23 81.94,45.16 82.27,45.22 82.52,45.13 82.62,45.29 82.61,45.42 82.33,45.52 82.32,45.59 82.97,46.97 83.03,47.19 84.02,46.97 84.67,46.97 84.79,46.83 85.23,47.04 85.48,47.06 85.66,47.25 85.53,47.92 85.75,48.39 86.55,48.53 86.72,48.70 86.73,48.94 86.81,49.05 87.05,49.11 87.32,49.09
|
||||||
|
23.48,53.94 24.19,53.95 24.32,53.89 24.77,53.97 24.87,54.15 25.05,54.13 25.46,54.29 25.51,54.16 25.75,54.16 25.75,54.26 25.55,54.33 25.72,54.56 25.72,54.72 25.86,54.92 26.18,55.00 26.29,55.14 26.60,55.13 26.78,55.27 26.46,55.34 26.59,55.67
|
||||||
|
-53.37,-33.74 -53.53,-33.66 -53.53,-33.17 -53.13,-32.74 -53.16,-32.68 -53.60,-32.40 -53.76,-32.06 -54.22,-31.86 -54.59,-31.49 -54.90,-31.39 -55.04,-31.28 -55.17,-31.28 -55.56,-30.88 -55.63,-30.86 -55.81,-31.03
|
||||||
|
116.68,49.82 116.03,48.78 115.82,48.58 115.79,48.25 115.53,48.13 115.62,47.87 115.90,47.69 116.23,47.86 116.76,47.87 117.07,47.81 117.35,47.65 117.77,47.99 118.15,48.03 118.50,47.98 118.76,47.76 119.08,47.65 119.16,47.53 119.71,47.15 119.90,46.86 119.87,46.67 119.71,46.61 119.33,46.61 118.84,46.76 118.72,46.69 118.31,46.72 118.07,46.67 117.74,46.52 117.44,46.59 117.33,46.36 116.86,46.39 116.56,46.29 116.21,45.89 116.20,45.74 115.68,45.46 114.56,45.39 114.42,45.20 114.08,44.97 113.59,44.75 112.71,44.88 112.41,45.06 111.90,45.06 111.62,44.83 111.40,44.37 111.60,44.11 111.84,43.93 111.93,43.71 111.55,43.50 111.01,43.34 110.40,42.77 109.34,42.44 108.17,42.45 106.77,42.29 105.20,41.74 104.98,41.60 104.50,41.66 104.50,41.88 103.71,41.75 103.07,42.01 102.16,42.16 101.97,42.22 101.66,42.50 101.50,42.54 99.98,42.68 99.47,42.57 97.21,42.79 96.39,42.72 96.30,42.93 95.86,43.28 95.53,43.95 95.33,44.04 95.35,44.28 95.05,44.26 94.71,44.35 94.20,44.65 93.96,44.67 93.52,44.94 92.79,45.04 91.58,45.08 91.05,45.22 90.88,45.20 90.66,45.53 90.71,45.73 91.00,46.04 90.91,46.27 91.03,46.57 90.87,46.95 90.72,47.00 90.50,47.29 90.33,47.66 90.10,47.75 90.03,47.88 89.78,47.83 89.48,48.03 89.05,48.00 88.58,48.22 88.52,48.38 87.98,48.56 88.06,48.71 87.74,48.88 87.87,49.00 87.81,49.16
|
||||||
|
13.81,48.77 13.55,48.96 13.40,48.98 12.92,49.33 12.68,49.41 12.39,49.74 12.51,49.90 12.21,50.10 12.09,50.30 12.28,50.18 12.55,50.39 12.94,50.41 13.02,50.49 13.44,50.60 13.56,50.70 14.37,50.90 14.26,51.00 14.32,51.04 14.55,50.99 14.66,50.83 14.81,50.86
|
||||||
|
27.35,57.53 26.97,57.61 26.46,57.54 25.99,57.84 25.28,58.05 25.26,58.00 25.11,58.06 24.32,57.87
|
||||||
|
27.35,57.53 27.51,57.51 27.54,57.43 27.83,57.29 27.64,56.85 27.85,56.85 28.10,56.55 28.20,56.26 28.15,56.14
|
||||||
|
21.05,56.07 21.65,56.31 22.08,56.41 22.88,56.40 23.04,56.32 23.20,56.37 24.12,56.26 24.47,56.28 24.84,56.41 25.07,56.20 25.66,56.10 26.28,55.75 26.59,55.67
|
||||||
|
20.62,69.04 20.12,69.02 20.35,68.85 20.24,68.67 19.97,68.54 20.24,68.48 19.97,68.36 18.30,68.56 18.16,68.53 18.18,68.20 17.92,67.96 17.32,68.10 16.78,67.90 16.57,67.62 16.19,67.51 16.13,67.43 16.43,67.16 16.40,67.05 15.42,66.49 15.48,66.31 15.04,66.17 14.54,66.13 14.63,65.79 14.48,65.30 13.65,64.58 14.08,64.46 14.14,64.17 13.96,64.01 13.20,64.08 12.79,64.00 12.18,63.60 12.21,63.49 12.00,63.29 12.22,63.00 12.11,62.92 12.11,62.59 12.30,62.29 12.16,61.72 12.88,61.35 12.71,61.06 12.29,61.00 12.59,60.45 12.49,60.11 12.17,59.91 11.93,59.86 11.83,59.70 11.68,59.59 11.80,59.29 11.64,58.93 11.47,58.91 11.34,59.09 11.24,59.10 11.14,59.09 11.06,58.96 10.91,58.95
|
||||||
|
20.62,69.04 21.07,69.04 21.13,69.08 21.07,69.21 21.59,69.27 22.30,68.86 22.41,68.72 23.32,68.65 23.71,68.71 23.85,68.81 24.94,68.59 25.09,68.64 25.25,68.82 25.58,68.89 25.75,68.99 25.77,69.28 26.07,69.69 26.53,69.92 27.13,69.91 27.75,70.06 27.89,70.06 28.41,69.82 29.14,69.67 29.33,69.47 28.85,69.18 28.83,69.12 28.97,69.02
|
||||||
|
104.43,10.41 104.56,10.52 104.85,10.53 105.05,10.70 105.05,10.91 105.31,10.85 105.41,10.95 105.76,10.99 105.85,10.86 106.16,10.79 106.16,11.04 105.86,11.29 105.85,11.64 106.01,11.76 106.40,11.69 106.41,11.95 106.70,11.98 106.76,12.05 106.93,12.08 107.21,12.30 107.33,12.32 107.39,12.26 107.51,12.36 107.56,12.54 107.48,13.03 107.61,13.44 107.33,14.13 107.36,14.37 107.49,14.55 107.52,14.71
|
||||||
|
-69.51,-17.50 -69.31,-17.94 -69.09,-18.05 -69.15,-18.14 -68.97,-18.97 -68.46,-19.43 -68.70,-19.72 -68.56,-19.97 -68.60,-20.04 -68.76,-20.09 -68.69,-20.31 -68.76,-20.42 -68.48,-20.63 -68.56,-20.72 -68.56,-20.90 -68.44,-20.95 -68.20,-21.30 -68.19,-21.62 -68.08,-21.98 -67.99,-22.06 -67.88,-22.49 -67.88,-22.82 -67.58,-22.89 -67.19,-22.82
|
||||||
|
-69.51,-17.51 -69.85,-17.70 -69.80,-17.99 -69.93,-18.21 -70.18,-18.33 -70.42,-18.35
|
||||||
|
6.34,49.45 6.49,49.80 6.20,49.92 6.11,50.03 6.12,50.12
|
||||||
|
2.52,51.10 2.60,50.88 2.76,50.75 2.84,50.71 3.11,50.78 3.23,50.66 3.27,50.53 3.60,50.48 3.69,50.31 3.95,50.34 4.17,50.25 4.15,49.97 4.55,49.96 4.82,50.15 4.86,50.14 4.79,49.96 4.87,49.79 5.28,49.68 5.51,49.51 5.79,49.54
|
||||||
|
6.12,50.12 5.98,50.17 5.74,49.92 5.72,49.81 5.88,49.64 5.79,49.54
|
||||||
|
46.43,41.89 45.95,42.04 45.64,42.21 45.71,42.50 45.34,42.53 45.16,42.68 44.87,42.76 44.77,42.62 44.64,42.73 44.51,42.75 43.83,42.57 43.74,42.62 43.78,42.75 43.09,42.99 42.99,43.09 42.76,43.17 42.57,43.16 42.42,43.22 41.58,43.22 40.65,43.53 40.15,43.57 39.98,43.42
|
||||||
|
20.96,40.85 20.93,40.90 20.71,40.93 20.49,41.27 20.45,41.52 20.52,41.57 20.57,41.87
|
||||||
|
20.57,41.87 20.58,42.01 20.49,42.22 20.24,42.34 20.06,42.55
|
||||||
|
41.51,41.52 41.82,41.43 41.93,41.50 42.47,41.44 42.61,41.58 42.75,41.58 43.15,41.31 43.21,41.20 43.40,41.18 43.44,41.11
|
||||||
|
101.14,21.57 101.20,21.52 101.25,21.20 101.54,21.23 101.70,21.15 101.80,21.21 101.72,21.31 101.74,21.83 101.60,21.99 101.52,22.25 101.74,22.50 101.84,22.39 102.02,22.44 102.13,22.38
|
||||||
|
73.63,39.45 73.82,39.49 73.91,39.58 73.84,39.80 73.99,40.04 74.41,40.14 74.68,40.31 74.83,40.33 74.84,40.48 75.11,40.45 75.56,40.63 75.68,40.31 75.87,40.30 76.26,40.43 76.32,40.35 76.48,40.45 76.66,40.78 76.91,41.02 77.58,40.99 78.12,41.08 78.44,41.42 79.29,41.78 79.77,41.90 79.84,42.00 80.22,42.03 80.21,42.19
|
||||||
|
43.44,41.11 44.23,41.21 44.84,41.21 44.81,41.26 45.00,41.29
|
||||||
|
80.21,42.19 79.92,42.41 79.43,42.48 79.13,42.78 76.99,42.97 75.84,42.94 75.64,42.81 75.37,42.84 74.82,42.98 74.21,43.24 73.95,43.20 73.56,43.00 73.42,42.59 73.49,42.41 72.28,42.76 71.76,42.82 71.26,42.73 70.89,42.34 70.95,42.25
|
||||||
|
-56.48,1.94 -56.84,1.88 -57.12,2.01 -57.32,1.96 -57.55,1.73 -57.98,1.65 -58.03,1.52 -58.34,1.59 -58.40,1.48 -58.51,1.44 -58.51,1.28 -58.68,1.28 -58.82,1.20 -59.23,1.38 -59.54,1.70 -59.67,1.75 -59.67,1.84 -59.76,1.90 -59.76,2.27 -59.89,2.36 -59.99,2.69 -59.97,2.99 -59.83,3.35 -59.85,3.59 -59.68,3.70 -59.55,3.93 -59.74,4.23 -59.70,4.38 -59.83,4.48 -60.15,4.53 -60.03,4.74 -59.99,5.08 -60.14,5.24 -60.74,5.20
|
||||||
|
8.39,55.10 8.59,54.92 9.25,54.81 9.74,54.83
|
||||||
|
9.52,30.23 9.90,30.39 10.22,30.78 10.26,30.94 10.11,31.46 10.27,31.68 10.48,31.74 10.61,31.93 10.83,32.08 11.51,32.41 11.53,32.52 11.45,32.64 11.50,33.18
|
||||||
|
45.00,41.29 45.19,41.15 45.07,41.08 45.42,40.99 45.59,40.85 45.38,40.67 45.45,40.53 45.57,40.42 45.96,40.23 45.89,40.02 45.58,39.98 46.20,39.59 46.48,39.56 46.48,39.48 46.37,39.40 46.58,39.22 46.40,39.19 46.49,39.07 46.49,38.91
|
||||||
|
44.77,39.70 45.03,39.77 45.17,39.57 45.25,39.60 45.46,39.49 45.78,39.55 45.77,39.38 45.98,39.24 45.95,39.18 46.11,38.88
|
||||||
|
20.24,46.11 20.51,46.17 20.61,46.13 20.76,46.25 21.12,46.28 21.26,46.41 21.32,46.61 21.50,46.70 21.66,47.04 22.00,47.40 22.00,47.51 22.29,47.73 22.61,47.77 22.88,47.95
|
||||||
|
70.96,40.24 71.30,40.29 71.63,40.22 71.69,40.15 71.96,40.26 72.13,40.44 72.36,40.40 72.40,40.58 72.60,40.53 73.14,40.81 72.66,40.87 72.36,41.04 72.19,41.03 72.16,41.17 71.88,41.20 71.86,41.31 71.66,41.54 71.60,41.50 71.59,41.33 71.42,41.34 71.39,41.12 70.86,41.22 70.69,41.45 70.47,41.41 70.20,41.51 70.18,41.57 70.63,41.88 70.80,41.92 70.86,42.03 71.23,42.16 71.04,42.28 70.95,42.25
|
||||||
|
18.83,49.51 18.94,49.50 18.97,49.40 19.15,49.40 19.44,49.60 19.63,49.41 19.77,49.37 19.76,49.20 20.06,49.18 20.16,49.32 20.36,49.39 20.62,49.39 20.87,49.31 21.08,49.42 21.64,49.41 21.89,49.34 22.02,49.21 22.54,49.07
|
||||||
|
-7.22,55.09 -7.38,55.03 -7.55,54.77 -7.91,54.70 -7.75,54.59 -8.12,54.48 -8.12,54.41 -7.85,54.22 -7.61,54.14 -7.32,54.13 -7.16,54.24 -7.20,54.30 -7.01,54.41 -6.80,54.21 -6.65,54.16 -6.65,54.06 -6.22,54.09
|
||||||
|
18.83,49.51 18.81,49.61 18.59,49.76 18.56,49.88 18.03,50.04 17.87,49.97 17.63,50.12 17.59,50.16 17.74,50.23 17.70,50.31 17.42,50.25 16.88,50.43 16.99,50.24 16.64,50.10 16.21,50.42 16.42,50.57 16.28,50.66 16.01,50.61 15.73,50.74 15.35,50.81 15.26,50.96 14.99,51.01 14.98,50.89 14.81,50.86
|
||||||
|
70.96,40.24 70.95,40.19 70.64,40.08 70.62,40.00 70.52,39.95 70.45,40.05 69.97,40.20 69.53,40.10 69.48,39.92 69.31,39.97 69.28,39.92 69.23,39.76 69.30,39.52 70.50,39.59 70.80,39.39 71.00,39.41 71.07,39.49 71.47,39.60 71.50,39.48 71.73,39.42 71.73,39.31 71.78,39.28 72.04,39.35 72.23,39.21 72.36,39.34 72.56,39.38 73.11,39.36 73.63,39.45
|
||||||
|
20.00,39.71 20.25,39.68 20.31,39.80 20.38,39.80 20.31,39.98 20.66,40.12 20.81,40.45 20.95,40.49 21.03,40.62 20.96,40.85
|
||||||
|
33.20,-14.01 33.15,-13.94 32.99,-14.02 32.87,-13.82 32.77,-13.76 32.80,-13.69 32.67,-13.59 32.85,-13.46 32.97,-13.22 33.00,-12.90 32.95,-12.80 33.02,-12.63 33.40,-12.49 33.51,-12.35 33.34,-12.31 33.25,-12.11 33.30,-11.69 33.23,-11.42 33.38,-11.16 33.26,-10.89 33.46,-10.78 33.66,-10.55 33.53,-10.23 33.31,-10.04 33.35,-9.86 33.20,-9.63 33.00,-9.62 32.92,-9.41
|
||||||
|
22.13,48.41 22.14,48.57 22.30,48.69 22.54,49.07
|
||||||
|
-11.51,6.91 -11.27,7.23 -10.65,7.76 -10.57,8.07 -10.36,8.19 -10.28,8.49
|
||||||
|
24.15,8.67 24.22,8.61 24.21,8.37 24.29,8.29 24.85,8.14 25.20,7.81 25.25,7.72 25.18,7.56 25.28,7.43 25.89,7.06 26.36,6.64 26.31,6.46 26.51,6.07 27.14,5.72 27.33,5.19
|
||||||
|
42.92,11.00 43.25,11.50
|
||||||
|
42.92,11.00 42.56,11.08 41.96,10.94 41.80,10.98 41.79,11.69 42.38,12.47
|
||||||
|
29.40,-4.45 29.32,-4.90 29.61,-5.72 29.48,-6.03 29.54,-6.31 29.71,-6.62 30.21,-7.04 30.75,-8.19
|
||||||
|
42.38,12.47 42.48,12.51 42.70,12.38 42.87,12.62 43.12,12.71
|
||||||
|
13.81,48.77 14.05,48.60 14.69,48.60 14.79,48.75 14.92,48.77 14.97,48.98 15.07,49.00 15.83,48.86 16.06,48.75 16.37,48.74 16.54,48.80 16.88,48.70 16.95,48.60
|
||||||
|
42.36,37.11 42.77,37.37 42.94,37.32 43.09,37.37 43.68,37.23 44.11,37.30 44.19,37.25 44.20,37.05 44.28,36.98 44.61,37.18 44.77,37.14
|
||||||
|
16.09,46.86 16.25,46.97 16.45,47.01 16.49,47.12 16.42,47.22 16.44,47.40 16.62,47.45 16.68,47.54 16.64,47.61 16.42,47.67 16.59,47.75 16.79,47.68 17.07,47.71 17.03,47.84 17.15,48.01
|
||||||
|
7.02,45.93 7.13,45.88 7.54,45.98 7.79,45.92 7.99,46.02 8.13,46.16 8.10,46.27 8.42,46.45 8.46,46.25 8.64,46.11 8.82,46.08 8.78,46.00 8.95,45.83 9.02,45.85 9.00,46.01 9.25,46.29 9.26,46.48 9.43,46.48 9.53,46.31 9.94,46.36 10.04,46.24 10.13,46.24 10.04,46.48 10.09,46.60 10.43,46.55 10.40,46.67 10.45,46.86
|
||||||
|
10.45,46.86 10.35,46.98 10.13,46.85 9.88,46.94 9.85,47.01 9.58,47.06
|
||||||
|
44.77,37.14 44.79,37.29 44.57,37.44 44.55,37.66 44.59,37.71 44.21,37.91 44.45,38.33 44.30,38.39 44.27,38.84 44.17,38.93 44.18,39.14 44.08,39.22 44.02,39.38 44.39,39.42 44.46,39.67 44.59,39.77 44.82,39.65
|
||||||
|
23.48,53.94 23.45,54.14 23.37,54.20 22.89,54.39 22.77,54.36
|
||||||
|
30.75,-8.19 30.89,-8.47 31.03,-8.60 31.35,-8.61 31.53,-8.71 31.67,-8.91 31.92,-8.94 31.94,-9.05 32.43,-9.16 32.92,-9.41
|
||||||
|
7.20,53.28 7.19,53.00 7.03,52.65 6.71,52.62 6.75,52.46 6.97,52.44 7.04,52.38 6.98,52.21 6.72,52.08 6.80,51.97 6.52,51.85 6.36,51.82 6.17,51.88 5.95,51.80 6.20,51.45 6.08,51.22 6.13,51.15 5.86,51.03 6.05,50.90 5.99,50.75
|
||||||
|
9.58,47.06 9.61,47.11 9.53,47.27
|
||||||
|
-8.49,7.56 -8.43,7.60 -8.23,7.56 -8.01,8.08 -8.05,8.17 -8.26,8.25 -8.24,8.46 -8.05,8.50 -7.82,8.47 -7.74,8.38 -7.68,8.41 -7.72,8.64 -7.95,8.79 -7.94,8.98 -7.78,9.08 -7.92,9.19 -7.90,9.42 -8.03,9.40 -8.14,9.50 -8.16,9.97 -7.99,10.16
|
||||||
|
7.49,43.77 7.48,43.86 7.68,44.08 7.64,44.16 7.32,44.14 6.90,44.34 6.84,44.51 7.03,44.72 6.99,44.83 6.74,44.92 6.63,45.07 6.69,45.14 6.84,45.14 7.08,45.24 7.15,45.38 6.79,45.74 6.80,45.81 7.02,45.93
|
||||||
|
28.97,69.02 28.41,68.90 28.77,68.84 28.48,68.54 28.56,68.35 28.69,68.19 29.34,68.06 29.99,67.67 29.94,67.55 29.24,67.10 29.07,66.89 29.90,66.09 30.09,65.79 30.10,65.68 29.72,65.62 29.82,65.57 29.73,65.47 29.71,65.34 29.61,65.25 29.81,65.20 29.83,65.15 29.62,65.04 29.60,64.97 29.78,64.80 30.11,64.73 30.12,64.64 29.99,64.52 30.11,64.37 30.49,64.24 30.53,64.08 30.21,63.80 29.99,63.74 31.18,63.21 31.53,62.89 31.19,62.48 29.25,61.29 28.41,60.90 27.73,60.46
|
||||||
|
30.87,69.78 30.92,69.61 30.86,69.54 30.62,69.53 30.18,69.64 30.19,69.54 30.09,69.43 29.39,69.30 29.21,69.10 28.97,69.02
|
||||||
|
55.19,22.70 55.20,23.03 55.53,23.82 55.47,23.94 55.99,24.06 55.93,24.22 55.76,24.24 55.80,24.87 55.92,24.97 56.00,24.95 55.97,24.86 56.06,24.74 56.39,24.98
|
||||||
|
48.57,41.84 48.39,41.60 48.06,41.46 47.79,41.20 47.32,41.28 47.14,41.52 46.75,41.81 46.57,41.80 46.54,41.87 46.43,41.89
|
||||||
|
-54.62,2.33 -54.72,2.44 -54.88,2.45 -54.98,2.60 -55.39,2.44 -55.73,2.41 -55.96,2.52 -56.14,2.26 -55.92,2.04 -55.93,1.89 -56.02,1.84 -56.48,1.94
|
||||||
|
46.43,41.89 46.20,41.74 46.18,41.66 46.31,41.51 46.67,41.29 46.53,41.09 46.43,41.08 46.17,41.20 45.92,41.19 45.73,41.26 45.72,41.34 45.28,41.45 45.00,41.29
|
||||||
|
-11.39,12.40 -11.45,12.56 -11.39,12.94 -11.63,13.37 -11.76,13.39 -11.83,13.32 -12.05,13.63 -11.96,13.88 -12.02,13.97 -12.02,14.21 -12.23,14.46 -12.19,14.65 -12.28,14.81
|
||||||
|
-54.62,2.33 -54.13,2.12 -53.77,2.35 -53.51,2.25 -53.33,2.34 -53.23,2.20 -52.90,2.21 -52.58,2.53 -52.33,3.18 -51.99,3.70 -51.65,4.06
|
||||||
|
2.71,6.37 2.77,6.71 2.72,6.98 2.79,7.48 2.69,7.87 2.77,9.05 3.04,9.08 3.15,9.32 3.14,9.45 3.33,9.67 3.35,9.81 3.56,9.91 3.65,10.16 3.58,10.29 3.65,10.41 3.77,10.42 3.83,10.61 3.72,11.08 3.49,11.40 3.60,11.70
|
||||||
|
13.07,-4.63 12.80,-4.43 12.38,-4.62 12.31,-4.77 12.20,-4.78 12.02,-5.00
|
||||||
|
16.52,46.50 16.32,46.53 16.24,46.48 16.23,46.37 16.07,46.37 15.64,46.20 15.59,46.14 15.67,46.05 15.65,45.86 15.28,45.73 15.35,45.66 15.28,45.58 15.34,45.47 14.79,45.48 14.57,45.66 14.37,45.48 13.99,45.51 13.88,45.43 13.58,45.52
|
||||||
|
18.91,45.93 18.67,45.91 18.53,45.80 18.36,45.75 17.81,45.79 17.31,46.00 16.87,46.34 16.52,46.50
|
||||||
|
20.24,46.11 19.61,46.17 19.28,46.00 19.09,46.02 18.91,45.93
|
||||||
|
16.52,46.50 16.28,46.86 16.09,46.86
|
||||||
|
51.27,24.61 50.97,24.57 50.80,24.79
|
||||||
|
25.26,-17.79 25.24,-17.97 25.94,-18.94 25.95,-19.08 26.17,-19.54 26.68,-19.89 27.18,-20.10 27.26,-20.23 27.28,-20.48 27.68,-20.50 27.67,-21.06 28.01,-21.55 29.03,-21.80 29.04,-22.02 29.36,-22.19
|
||||||
|
77.80,35.50 77.45,35.48 76.77,35.66
|
||||||
|
28.01,41.97 27.53,41.92 27.24,42.09 27.01,42.06 26.58,41.95 26.51,41.83 26.36,41.80 26.32,41.72
|
||||||
|
102.93,11.71 102.74,12.09 102.76,12.43 102.50,12.67 102.46,13.02 102.33,13.29 102.34,13.56 102.55,13.59 102.54,13.66 102.91,14.14 103.20,14.33 103.60,14.42 103.98,14.36 104.78,14.43 104.97,14.37 105.00,14.25 105.07,14.23 105.18,14.35
|
||||||
|
88.89,27.32 88.74,27.18 88.86,26.96 89.15,26.82 89.33,26.85 89.55,26.80 89.61,26.72 90.12,26.75 90.35,26.89 90.74,26.77 91.29,26.79 91.43,26.87 91.67,26.80 92.05,26.87 92.07,26.98 91.99,27.10 92.08,27.29 91.99,27.45 91.74,27.44 91.59,27.56 91.63,27.76
|
||||||
|
104.07,1.28 104.06,1.40 103.82,1.46 103.70,1.44 103.60,1.21
|
||||||
|
100.12,6.44 100.18,6.67 100.26,6.68 100.35,6.55 100.75,6.46 100.87,6.25 101.05,6.24 101.08,5.96 100.98,5.77 101.11,5.64 101.26,5.79 101.56,5.91 101.68,5.78 101.87,5.83 102.10,6.24
|
||||||
|
5.99,50.75 6.24,50.60 6.17,50.55 6.20,50.50 6.34,50.45 6.36,50.32 6.18,50.23 6.12,50.12
|
||||||
|
-71.78,19.72 -71.71,19.49 -71.75,19.29 -71.65,19.16 -71.81,18.99 -71.74,18.73 -71.87,18.61 -72.00,18.60 -71.76,18.34 -71.77,18.04
|
||||||
|
14.06,13.08 13.61,13.70
|
||||||
|
46.53,29.10 46.91,29.54 47.15,30.00 47.33,30.08 47.64,30.10 47.98,29.98
|
||||||
|
88.11,27.87 88.14,27.95 88.58,28.09 88.80,28.01 88.85,27.87 88.75,27.52 88.89,27.32
|
||||||
|
-90.10,13.74 -90.05,13.90 -89.55,14.24 -89.57,14.39 -89.36,14.42
|
||||||
|
124.04,-9.34 124.09,-9.42 124.28,-9.43 124.41,-9.31 124.44,-9.19
|
||||||
|
124.92,-8.94 124.94,-9.05 125.10,-9.00 125.15,-9.04 125.10,-9.19 124.96,-9.21 125.07,-9.51
|
||||||
|
115.14,4.90 115.27,4.63 115.29,4.35 115.11,4.39 115.03,4.90
|
||||||
|
109.63,2.03 109.54,1.90 109.64,1.78 109.65,1.61 110.51,0.86 111.10,1.05 111.81,1.01 111.92,1.11 112.08,1.14 112.19,1.44 112.48,1.56 112.94,1.57 113.01,1.43 113.62,1.24 113.90,1.43 114.39,1.50 114.51,1.45 114.70,1.85 114.83,1.98 114.76,2.16 114.79,2.25 115.18,2.52 115.08,2.63 115.12,2.89 115.25,3.03 115.45,3.03 115.57,3.45 115.57,3.94 115.68,4.19 115.84,4.33 116.02,4.29 116.24,4.36 116.41,4.31 116.51,4.37 117.10,4.34 117.54,4.17 117.88,4.19
|
||||||
|
10.45,46.86 10.99,46.78 11.24,46.98 11.78,46.99 12.17,47.08 12.15,46.94 12.39,46.70 13.70,46.52
|
||||||
|
13.70,46.52 13.38,46.26 13.63,46.18 13.48,46.01 13.61,45.96 13.58,45.81 13.87,45.61 13.72,45.59
|
||||||
|
29.01,-2.72 29.10,-2.60 29.30,-2.67 29.39,-2.81 29.70,-2.79 29.87,-2.72 29.93,-2.34 30.12,-2.42 30.41,-2.31 30.55,-2.40
|
||||||
|
92.57,21.98 92.63,21.31 92.57,21.26 92.33,21.44 92.18,21.29 92.32,20.79
|
||||||
|
1.43,42.60 1.29,42.71 0.70,42.85 0.63,42.69 -0.04,42.69 -0.30,42.83 -0.59,42.80 -0.76,42.94 -1.18,43.02 -1.30,43.10 -1.46,43.05 -1.41,43.24 -1.71,43.31 -1.79,43.41
|
||||||
|
1.43,42.60 1.50,42.64 1.71,42.60 1.71,42.50
|
||||||
|
1.71,42.50 2.03,42.35 2.20,42.42 2.65,42.34 2.89,42.46 3.21,42.43
|
||||||
|
1.43,42.60 1.45,42.44 1.71,42.50
|
||||||
|
74.89,37.23 75.12,37.39 74.89,37.60 74.94,37.77 74.78,38.19 74.81,38.46 74.28,38.66 74.13,38.66 74.03,38.54 73.80,38.61 73.70,38.85 73.81,38.97 73.61,39.23 73.63,39.45
|
||||||
|
74.89,37.23 74.73,37.29 74.37,37.16 74.54,37.02
|
||||||
|
20.96,40.85 21.40,40.91 21.58,40.87 21.99,41.13 22.60,41.14 22.72,41.18 22.78,41.33 22.92,41.34
|
||||||
|
21.56,42.25 21.39,42.22 21.29,42.10 21.06,42.17 20.78,42.07 20.72,41.87 20.57,41.87
|
||||||
|
22.34,42.31 22.24,42.36 21.56,42.25
|
||||||
|
22.34,42.31 22.52,42.44 22.44,42.63 22.47,42.84 22.71,42.88 22.98,43.19 22.55,43.45 22.37,43.78 22.42,44.01 22.60,44.08 22.63,44.19 22.71,44.24
|
||||||
|
22.92,41.34 23.00,41.74 22.84,41.99 22.58,42.10 22.34,42.31
|
||||||
|
18.44,42.56 18.52,42.43
|
||||||
|
18.44,42.56 17.80,42.90 17.67,42.90
|
||||||
|
78.92,33.39 79.12,33.23
|
||||||
|
20.34,42.83 20.47,42.86 20.48,42.95 20.62,43.03 20.62,43.20 20.80,43.26 20.84,43.17 21.06,43.09 21.40,42.83 21.39,42.75 21.75,42.67 21.61,42.39 21.52,42.33 21.56,42.25
|
||||||
|
20.34,42.83 20.05,42.76 20.06,42.55
|
||||||
|
19.19,43.53 19.22,43.45 19.61,43.17 20.27,42.94 20.34,42.83
|
||||||
|
-89.36,14.42 -89.12,14.37 -88.71,14.03 -88.51,13.98 -88.48,13.85 -88.15,13.99 -87.99,13.88 -87.80,13.89 -87.72,13.81 -87.78,13.52 -87.74,13.45 -87.81,13.40
|
||||||
|
-87.34,12.98 -87.01,13.01 -86.92,13.22 -86.73,13.28 -86.76,13.75 -86.33,13.77 -86.04,14.05 -85.98,13.97 -85.73,13.86 -85.68,13.98 -85.18,14.34 -85.16,14.53 -85.04,14.61 -84.99,14.75 -84.86,14.81 -84.65,14.66 -84.45,14.64 -84.24,14.75 -84.15,14.72 -83.64,14.88 -83.54,14.98 -83.16,14.99
|
||||||
|
-75.28,-0.11 -75.78,0.09 -76.07,0.35 -76.27,0.44 -76.39,0.40 -76.43,0.26 -76.49,0.24 -76.83,0.25 -77.40,0.39 -77.47,0.64 -77.65,0.72 -77.70,0.84 -77.83,0.83 -78.18,0.97 -78.86,1.46
|
||||||
|
-57.60,3.37 -57.65,3.52 -57.83,3.68 -58.03,4.00 -58.05,4.17 -57.85,4.67 -57.92,4.82 -57.88,4.88 -57.71,4.99 -57.33,5.02 -57.21,5.20 -57.32,5.34 -57.19,5.55
|
||||||
|
-54.08,3.33 -53.99,3.59 -54.35,4.05 -54.48,4.84 -54.45,5.01 -54.16,5.36
|
||||||
|
-58.41,-33.94 -58.48,-33.65 -58.38,-33.14 -58.24,-33.11 -58.13,-32.98 -58.20,-32.47
|
||||||
|
-57.61,-30.19 -57.87,-30.59 -57.81,-30.86 -57.90,-30.98 -57.89,-31.20 -58.05,-31.49 -57.99,-31.58 -58.01,-31.68 -58.19,-31.92 -58.12,-32.25 -58.20,-32.47
|
||||||
|
-54.62,-25.58 -54.44,-25.62 -54.15,-25.52 -53.89,-25.67 -53.67,-26.29 -53.75,-26.75 -53.72,-26.88 -53.84,-27.12 -54.16,-27.25 -54.33,-27.42 -54.83,-27.55 -54.91,-27.71 -55.10,-27.87 -55.73,-28.20 -55.69,-28.38 -55.89,-28.37 -56.94,-29.59 -57.22,-29.78 -57.41,-30.03 -57.61,-30.19
|
||||||
|
20.62,69.04 20.90,68.98 20.92,68.91 22.00,68.52 22.85,68.37 23.10,68.26 23.18,68.14 23.32,68.13 23.64,67.95 23.50,67.88 23.54,67.61 23.45,67.46 23.73,67.42 23.77,67.33 23.63,67.23 23.64,67.13 23.99,66.81 23.67,66.38 23.72,66.22 23.91,66.15 24.16,65.81 24.19,65.61 24.12,65.51
|
||||||
|
4.23,51.39 4.17,51.31 3.90,51.21 3.58,51.29 3.43,51.25 3.35,51.38
|
||||||
|
5.99,50.75 5.75,50.76 5.64,50.84 5.75,50.95 5.80,51.15 5.48,51.29 5.21,51.28 5.03,51.47 4.85,51.40 4.76,51.49 4.63,51.42 4.50,51.47 4.38,51.43 4.37,51.36 4.23,51.39
|
||||||
|
6.34,49.45 6.53,49.39 6.74,49.16 6.89,49.21 7.04,49.11 7.45,49.15 7.61,49.06 8.13,48.97 8.14,48.89 7.84,48.64 7.62,48.16 7.53,47.67 7.62,47.59
|
||||||
|
9.52,47.52 9.18,47.67 8.88,47.66 8.57,47.78 8.40,47.69 8.57,47.65 8.45,47.60 7.62,47.59
|
||||||
|
9.53,47.27 9.63,47.47 9.52,47.52
|
||||||
|
9.58,47.06 9.49,47.06 9.53,47.27
|
||||||
|
7.02,45.93 6.77,46.17 6.82,46.28 6.76,46.42 6.43,46.43 6.23,46.33 6.27,46.25 6.20,46.19 6.01,46.14 5.97,46.21 6.10,46.28 6.12,46.38 6.07,46.46 6.16,46.61 6.41,46.76 6.46,46.95 6.67,47.03 7.00,47.32 6.90,47.39 6.97,47.45 7.14,47.49 7.34,47.43 7.62,47.59
|
||||||
|
-7.41,37.18 -7.50,37.59 -7.44,37.73 -7.19,38.01 -7.02,38.04 -6.96,38.19 -7.11,38.18 -7.34,38.46 -7.28,38.71 -7.05,38.91 -7.00,39.06 -7.17,39.14 -7.34,39.47 -7.54,39.66 -7.12,39.68 -6.98,39.80 -6.90,40.02 -7.03,40.17 -6.81,40.34 -6.84,40.78 -6.93,41.01 -6.21,41.53 -6.31,41.64 -6.54,41.67 -6.62,41.94 -7.15,41.98 -7.21,41.90 -7.40,41.83 -7.92,41.88 -8.15,41.81 -8.22,41.90 -8.13,42.02 -8.27,42.14 -8.78,41.94
|
||||||
|
13.81,48.77 13.79,48.59 13.72,48.54 13.49,48.58 13.37,48.36 12.76,48.11 12.95,47.89 12.90,47.72 13.05,47.66 13.01,47.48 12.81,47.54 12.77,47.64 12.69,47.67 12.48,47.64 12.21,47.72 12.19,47.62 11.72,47.58 11.04,47.39 10.87,47.52 10.44,47.55 10.37,47.37 10.18,47.28 10.20,47.36 10.07,47.39 9.97,47.51 9.75,47.58 9.52,47.52
|
||||||
|
16.09,46.86 15.98,46.80 15.96,46.68 15.77,46.71 15.44,46.63 14.89,46.61 14.55,46.40 13.70,46.52
|
||||||
|
18.44,42.56 18.55,42.64 18.47,42.78 18.46,43.00 18.62,43.03 18.67,43.23 18.85,43.35 19.03,43.29 18.95,43.53 19.19,43.53
|
||||||
|
19.19,43.53 19.30,43.59 19.45,43.56 19.50,43.64 19.25,43.97 19.58,44.01 19.12,44.36 19.35,44.88 18.84,44.88 18.66,45.08 18.00,45.14 17.81,45.08 17.65,45.16 17.50,45.12 17.13,45.17 16.92,45.28 16.79,45.20 16.53,45.22 16.29,45.01 16.03,45.19 15.79,45.18 15.74,44.77 16.10,44.52 16.30,44.12 17.08,43.52 17.27,43.45 17.29,43.31 17.65,43.01 17.59,42.94
|
||||||
|
19.01,44.87 19.10,44.97 19.06,45.14 19.14,45.20 19.40,45.21 19.00,45.40 19.06,45.52 18.92,45.60 18.95,45.66 18.84,45.84 18.91,45.93
|
||||||
|
16.95,48.60 17.14,48.84 17.76,48.89 18.09,49.07 18.16,49.26 18.60,49.49 18.83,49.51
|
||||||
|
17.15,48.01 16.87,48.39 16.95,48.60
|
||||||
|
17.15,48.01 17.32,47.99 17.76,47.77 18.72,47.79 18.79,48.00 19.47,48.11 19.63,48.22 19.95,48.15 20.33,48.30 20.49,48.53 21.45,48.55 21.72,48.35 22.13,48.41
|
||||||
|
14.81,50.86 15.02,51.25 14.91,51.46 14.72,51.52 14.74,51.63 14.60,51.83 14.75,52.08 14.55,52.36 14.62,52.53 14.13,52.88 14.41,53.22 14.21,53.95
|
||||||
|
22.88,47.95 22.77,48.11 22.58,48.13 22.35,48.26 22.25,48.41 22.13,48.41
|
||||||
|
28.59,43.74 28.22,43.77 28.05,43.82 27.88,43.99 27.74,43.96 27.43,44.02 27.09,44.17 26.22,44.01 25.82,43.77 25.50,43.67 23.22,43.87 22.92,43.83 22.87,43.95 23.03,44.08 22.71,44.24
|
||||||
|
20.24,46.11 20.71,45.74 20.77,45.75 20.79,45.47 21.49,45.15 21.35,45.01 21.53,44.90 21.36,44.83
|
||||||
|
22.92,41.34 23.64,41.39 24.01,41.46 24.06,41.53 24.49,41.56 24.77,41.36 24.99,41.36 25.25,41.24 25.92,41.31 26.14,41.39 26.07,41.67 26.11,41.73 26.41,41.70 26.58,41.60 26.62,41.40 26.33,41.24 26.33,40.95 26.04,40.73
|
||||||
|
20.06,42.55 19.79,42.48 19.73,42.63 19.65,42.63 19.28,42.17 19.36,42.07 19.34,41.87
|
||||||
|
26.62,48.26 26.79,48.26 26.98,48.16 27.61,47.34 28.07,46.98 28.24,46.64 28.24,46.45 28.10,45.97 28.16,45.65 28.07,45.60 28.21,45.45
|
||||||
|
22.88,47.95 23.20,48.08 23.41,47.99 24.18,47.91 24.58,47.93 24.89,47.72 24.98,47.72 25.46,47.91 26.16,47.99 26.31,48.20 26.62,48.26
|
||||||
|
22.54,49.07 22.81,49.02 22.85,49.06 22.71,49.17 22.73,49.30 22.65,49.54 22.71,49.61 23.71,50.38 23.97,50.41 24.09,50.53 23.98,50.79 24.10,50.87 23.66,51.31 23.61,51.52
|
||||||
|
20.90,55.29 21.39,55.28 22.07,55.06 22.57,55.06 22.82,54.87 22.68,54.56 22.77,54.36
|
||||||
|
31.76,52.10 31.58,52.31 31.62,52.55 31.52,52.70 31.56,52.76 31.26,53.02 31.42,53.20 31.67,53.20 31.85,53.11 32.14,53.09 32.70,53.34 32.71,53.42 32.47,53.55 32.45,53.69 32.20,53.78 31.75,53.81 31.83,54.03 31.40,54.20 31.18,54.45 31.07,54.49 31.15,54.63 30.80,54.78 30.83,54.91 30.98,55.05 30.81,55.31 30.90,55.40 30.91,55.57 30.23,55.85 29.94,55.85 29.48,55.68 29.35,55.78 29.38,55.94 29.03,56.02 28.79,55.94 28.56,56.09 28.28,56.06 28.15,56.14
|
||||||
|
31.76,52.10 32.12,52.05 32.28,52.11 32.44,52.31 32.81,52.25 33.15,52.34 33.74,52.34 33.92,52.25 34.11,51.98 34.40,51.78 34.38,51.72 34.12,51.68 34.27,51.34 34.21,51.26 34.71,51.17 35.06,51.20 35.16,51.06 35.31,51.04 35.44,50.73 35.41,50.54 35.59,50.37 35.67,50.35 35.89,50.44 36.12,50.41 36.31,50.28 36.50,50.28 36.62,50.21 36.76,50.29 37.42,50.41 37.70,50.11 38.05,49.92 38.26,50.05 38.92,49.82 39.17,49.86 39.30,49.74 39.46,49.73 39.78,49.57 40.08,49.58 40.11,49.25 39.89,49.06 39.69,49.01 39.75,48.91 40.00,48.82 39.79,48.81 39.64,48.59 39.84,48.54 39.89,48.36 39.85,48.30 39.96,48.27 39.78,47.89 39.66,47.84 38.90,47.86 38.64,47.67 38.37,47.61 38.29,47.56 38.20,47.32 38.28,47.28 38.20,47.18 38.21,47.09
|
||||||
|
43.44,41.11 43.63,40.93 43.72,40.72 43.57,40.48 43.71,40.17 43.67,40.13 44.01,40.01 44.29,40.04 44.78,39.68
|
||||||
|
87.82,49.16 88.12,49.26 88.19,49.45 88.83,49.45 88.90,49.54 89.01,49.47 89.11,49.50 89.24,49.63 89.40,49.61 89.65,49.72 89.64,49.90 89.98,49.98 90.05,50.09 90.66,50.22 91.02,50.42 91.42,50.47 91.80,50.69 92.19,50.70 92.35,50.86 92.63,50.69 92.78,50.78 92.94,50.78 93.10,50.60 94.25,50.56 94.35,50.22 94.61,50.02 94.93,50.04 95.11,49.94 95.52,49.91 95.79,50.01 95.94,49.96 96.07,50.00 96.32,49.90 96.99,49.88 97.21,49.73 97.36,49.74 97.59,49.91 98.00,50.01 98.25,50.30 98.28,50.53 98.03,50.64 97.84,51.05 98.04,51.45 98.22,51.51 98.35,51.72 98.64,51.80 98.89,52.12 99.72,51.87 99.92,51.76 100.47,51.73 101.38,51.45 102.11,51.35 102.22,50.83 102.32,50.72 102.29,50.59 102.68,50.39 103.16,50.29 103.30,50.20 103.63,50.14 104.08,50.15 104.47,50.31 105.38,50.47 106.22,50.30 106.71,50.31 107.23,49.99 107.92,49.95 107.97,49.65 108.61,49.32 109.24,49.33 110.71,49.14 111.34,49.36 112.08,49.42 112.49,49.53 112.81,49.52 113.06,49.62 113.16,49.80 113.57,50.01 114.30,50.27 114.74,50.23 115.43,49.90 115.72,49.88 116.22,50.01 116.55,49.92 116.68,49.82
|
||||||
|
116.68,49.82 117.25,49.62 117.87,49.51 118.45,49.84 119.26,50.07 119.35,50.28 119.16,50.41 119.45,50.70 120.07,51.60 120.51,51.85 120.75,52.10 120.67,52.30 120.70,52.49 120.66,52.57 120.17,52.60 120.07,52.63 120.04,52.72 120.70,53.17 120.99,53.28 122.34,53.49 123.61,53.55 124.81,53.13 125.08,53.20 125.65,53.04 125.73,52.89 125.87,52.87 126.05,52.74 126.02,52.61 126.34,52.36 126.51,51.93 126.70,51.70 126.71,51.57 126.81,51.51 126.92,51.10 127.31,50.71 127.34,50.35 127.59,50.21 127.49,49.98 127.55,49.80 127.71,49.67 128.00,49.57 128.70,49.60 128.82,49.46 129.07,49.37 129.50,49.39 130.20,48.89 130.55,48.86 130.62,48.77 130.55,48.60 130.80,48.34 130.71,48.13 130.73,48.02 130.92,47.84 130.96,47.71 132.48,47.71 132.71,47.95 133.14,48.11 133.47,48.10 133.84,48.27 134.29,48.37 134.67,48.25 134.67,48.15 134.57,48.02 134.75,47.72 134.54,47.49 134.29,47.41 134.17,47.30 134.20,47.13 133.87,46.50 133.86,46.25 133.70,46.14 133.65,45.96 133.51,45.88 133.44,45.60 133.19,45.49 133.11,45.13 132.94,45.03 131.98,45.24 131.85,45.33 131.45,44.98 130.98,44.84 131.26,44.07 131.17,43.70 131.18,43.51 131.26,43.38 131.07,42.90 130.58,42.81 130.42,42.73 130.58,42.62 130.53,42.54
|
||||||
|
130.53,42.54 130.25,42.74 130.24,42.89 129.90,43.00 129.70,42.45 129.52,42.38 129.31,42.41 129.20,42.22 128.92,42.04 128.05,41.99 128.03,41.90 128.29,41.61 128.15,41.39 128.01,41.45 127.69,41.44 127.18,41.53 126.95,41.77 126.72,41.72 126.60,41.64 126.49,41.36 125.99,40.90 125.73,40.87 125.42,40.66 124.89,40.46 124.39,40.10 124.36,40.00
|
||||||
|
130.53,42.54 130.69,42.30
|
||||||
|
70.96,40.24 70.65,40.20 70.37,40.38 70.38,40.45 70.75,40.72 70.40,41.04 70.29,40.89 69.71,40.66 69.41,40.80 69.36,40.77 69.31,40.63 69.21,40.57 69.30,40.33 69.22,40.29 69.27,40.20 68.95,40.22 68.63,40.17 68.97,40.09 68.80,40.05 68.87,39.91 68.64,39.84 68.59,39.63 68.46,39.54 67.72,39.62 67.43,39.47 67.36,39.22 67.65,39.13 67.69,38.99 68.04,38.98 68.13,38.93 68.05,38.67 68.09,38.47 68.35,38.21 68.29,38.03 67.81,37.49 67.76,37.17
|
||||||
|
35.89,35.92 36.15,35.83 36.20,35.94 36.35,36.00 36.38,36.17 36.64,36.23 36.54,36.46 36.66,36.80 36.94,36.76 37.07,36.65 37.44,36.64 38.19,36.90 38.44,36.86 38.77,36.69 39.36,36.68 40.02,36.83 40.71,37.10 41.52,37.09 42.06,37.21 42.20,37.30 42.31,37.23 42.36,37.11
|
||||||
|
35.45,31.48 35.40,31.23 35.44,31.13 35.14,30.42 35.14,30.14 34.97,29.56
|
||||||
|
35.55,32.40 35.56,31.77 35.45,31.48
|
||||||
|
35.61,32.68 35.55,32.40
|
||||||
|
88.11,27.87 87.14,27.84 86.61,28.10 86.52,27.96 86.41,27.93 86.14,28.11 86.08,28.08 86.06,27.93 85.99,27.91 85.68,28.28 85.12,28.32 85.09,28.37 85.16,28.59 84.80,28.56 84.23,28.91 84.10,29.22 83.94,29.28 83.58,29.18 83.16,29.61 82.85,29.68 82.22,30.06 82.04,30.33 81.64,30.39 81.42,30.34 81.18,30.04 81.11,30.04 81.01,30.16
|
||||||
|
97.32,28.22 97.34,27.94 96.88,27.59 96.90,27.44 97.10,27.12 96.95,27.13 96.73,27.33 96.19,27.26 95.46,26.76 95.13,26.60 95.05,26.35 95.13,26.04 95.02,25.91 94.95,25.70 94.58,25.32 94.55,25.22 94.71,25.05 94.29,24.32 94.13,23.88 93.68,24.01 93.49,23.97 93.33,24.06 93.41,23.68 93.39,23.34 93.35,23.08 93.31,23.03 93.16,23.03 93.08,22.72 93.15,22.23 93.04,22.18 92.96,22.00 92.85,22.01 92.69,22.13 92.57,21.98
|
||||||
|
100.12,20.32 99.89,20.42 99.72,20.33 99.46,20.36 99.49,20.15 99.34,20.08 99.07,20.10 98.92,19.77 98.37,19.69 98.02,19.75 97.82,19.46 97.80,19.13 97.71,18.93 97.75,18.59 97.52,18.50 97.37,18.52 97.45,18.36 97.63,18.29 97.74,17.94 97.71,17.80 98.44,16.98 98.48,16.73 98.66,16.33 98.84,16.42 98.89,16.35 98.82,16.18 98.59,16.05 98.56,15.37 98.19,15.20 98.20,14.98 98.25,14.81 98.57,14.36 98.93,14.05 99.14,13.72 99.18,13.23 99.12,13.03 99.22,12.74 99.41,12.55 99.61,11.78 99.19,11.11 98.76,10.66 98.70,10.19
|
||||||
|
103.09,18.14 103.09,18.14
|
||||||
|
103.09,18.14 103.26,18.28 103.29,18.41 103.95,18.32 104.43,17.70 104.74,17.46 104.82,17.30 104.74,16.88 104.82,16.47 105.05,16.16 105.41,15.99 105.40,15.83 105.64,15.66 105.49,15.26 105.55,14.93 105.50,14.59 105.42,14.47 105.18,14.35
|
||||||
|
100.12,20.32 100.14,20.25 100.32,20.39 100.52,20.18 100.54,20.09 100.40,19.76 100.51,19.55 100.74,19.51 100.97,19.61 101.21,19.55 101.20,19.33 101.29,18.98 101.05,18.44 101.14,18.14 100.91,17.58 100.96,17.54 101.17,17.50 101.69,17.89 101.77,18.03 101.95,18.08 102.10,18.21 102.55,17.97 102.66,17.82 102.81,17.95 103.05,18.03 103.09,18.14
|
||||||
|
101.14,21.57 101.08,21.47 100.70,21.25 100.54,20.99 100.52,20.92 100.62,20.86 100.25,20.73 100.12,20.32
|
||||||
|
97.32,28.22 97.54,28.51 97.60,28.52 98.06,28.19 98.30,27.55 98.45,27.66 98.65,27.57 98.74,26.79 98.69,26.19 98.56,26.07 98.66,25.86 98.47,25.79 98.33,25.59 98.14,25.57 98.01,25.29 97.82,25.25 97.71,25.03 97.74,24.87 97.58,24.77 97.53,24.49 97.67,24.38 97.71,24.23 97.56,23.91 97.69,23.90 98.21,24.11 98.58,24.07 98.84,24.12 98.68,23.91 98.83,23.62 98.80,23.52 98.88,23.38 98.86,23.19 99.42,23.07 99.51,22.96 99.39,22.83 99.34,22.50 99.21,22.28 99.19,22.13 99.92,22.03 99.94,21.76 100.10,21.66 100.15,21.48 100.60,21.47 101.08,21.76 101.13,21.74 101.14,21.57
|
||||||
|
102.13,22.38 102.58,21.90 102.66,21.68 102.74,21.68 102.82,21.81 102.95,21.68 102.85,21.27 103.10,20.89 103.64,20.70 104.10,20.95 104.58,20.65 104.37,20.44 104.62,20.37 104.70,20.21 104.85,20.20 104.93,20.02 104.59,19.62 104.03,19.68 104.06,19.48 103.89,19.30 104.72,18.80 105.15,18.65 105.11,18.41 105.33,18.19 105.46,18.15 105.69,17.74 106.50,16.95 106.55,16.65 106.66,16.49 106.74,16.45 106.85,16.52 106.93,16.35 107.40,16.04 107.39,15.95 107.17,15.80 107.65,15.26 107.48,14.98 107.52,14.71
|
||||||
|
27.40,5.11 27.07,5.20 26.82,5.06 25.53,5.31 25.40,5.26 25.25,5.02 25.07,4.97 24.77,4.93 24.32,4.99 23.42,4.66 23.12,4.74 22.86,4.72 22.71,4.59 22.42,4.13 21.69,4.28 21.54,4.24 20.56,4.46 20.23,4.83 19.81,5.09 19.50,5.13 19.07,4.89 18.83,4.52 18.59,4.35 18.61,3.48
|
||||||
|
30.55,-2.40 30.42,-2.64 30.47,-2.69 30.43,-2.87 30.78,-2.98 30.79,-3.27 30.63,-3.35 30.63,-3.42 30.43,-3.59 30.15,-4.09 29.95,-4.31 29.72,-4.46 29.40,-4.45
|
||||||
|
29.01,-2.72 29.02,-2.80 29.22,-3.05 29.21,-3.83 29.40,-4.45
|
||||||
|
29.58,-1.39 29.35,-1.52 29.20,-1.72 29.13,-1.86 29.13,-2.20 28.88,-2.40 28.89,-2.64 29.01,-2.72
|
||||||
|
35.27,5.49 34.98,5.86 34.71,6.66 34.06,7.23 33.90,7.51 33.67,7.67 33.23,7.76 33.00,7.90 33.28,8.44 33.95,8.44 34.07,8.55 34.08,9.46
|
||||||
|
22.86,10.92 22.49,11.00 22.04,10.82 21.77,10.64 21.71,10.57 21.68,10.29 21.53,10.21 21.40,10.00 21.26,9.97 20.77,9.41 20.34,9.13 20.07,9.13 19.67,9.02 19.15,9.02 18.96,8.94 18.89,8.84 19.11,8.66 18.56,8.05 17.65,7.98 16.78,7.55 16.55,7.87 16.40,7.77 16.38,7.68 15.96,7.51 15.48,7.52
|
||||||
|
13.29,2.16 13.22,2.26 11.56,2.30 11.35,2.30 11.33,2.17
|
||||||
|
11.13,-3.92 11.23,-3.69 11.50,-3.52 11.69,-3.68 11.88,-3.67 11.83,-3.53 11.93,-3.32 11.72,-3.18 11.76,-2.98 11.64,-2.86 11.54,-2.84 11.61,-2.34 11.73,-2.39 11.95,-2.34 12.06,-2.41 12.45,-2.33 12.43,-1.93 12.59,-1.83 12.79,-1.93 12.99,-2.31 13.46,-2.40 13.73,-2.14 13.88,-2.33 13.89,-2.47 13.99,-2.49 14.20,-2.35 14.16,-2.22 14.25,-2.00 14.38,-1.89 14.47,-0.57 14.21,-0.43 14.07,-0.27 13.86,-0.20 13.95,0.35 14.09,0.54 14.32,0.62 14.43,0.90 14.18,1.37 13.85,1.42 13.22,1.25 13.25,1.37 13.17,1.79 13.29,2.16
|
||||||
|
15.48,7.52 15.55,7.79 15.44,7.85 15.12,8.56 14.33,9.20 13.98,9.69 14.24,9.98 14.84,9.94 15.65,10.01 15.28,10.36 15.13,10.65 15.03,11.11 15.12,11.54 15.08,11.85 14.88,12.27 14.85,12.50 14.76,12.66 14.54,12.82 14.46,13.02 14.06,13.08
|
||||||
|
14.06,13.08 14.20,12.38 14.52,12.30 14.62,12.15 14.58,11.53 14.20,11.27 13.98,11.21 13.70,10.87 13.54,10.61 13.41,10.17 13.27,10.04 13.20,9.56 12.93,9.43 12.78,8.82 12.58,8.62 12.40,8.60 12.23,8.28 12.02,7.59 11.77,7.27 11.86,7.12 11.58,6.89 11.48,6.60 11.24,6.45 11.11,6.46 11.01,6.74 10.61,7.06 10.48,6.89 10.21,6.89 10.14,7.00 9.78,6.76 9.66,6.53 9.00,5.92 8.80,5.20 8.56,4.76
|
||||||
|
3.60,11.70 3.66,11.76 3.61,11.89 3.65,12.53 3.95,12.78 4.04,12.93 4.15,13.46 4.66,13.73 5.24,13.76 5.49,13.87 6.30,13.66 6.59,13.41 6.87,13.04 7.01,13.00 7.83,13.34 8.10,13.29 8.75,12.91 9.62,12.81 10.05,13.21 10.48,13.33 11.41,13.35 11.99,13.19 12.12,13.09 12.46,13.09 12.65,13.33 12.87,13.45 13.32,13.67 13.61,13.70
|
||||||
|
1.19,6.09 0.98,6.32 0.74,6.45 0.53,6.85 0.63,7.35 0.51,7.44 0.50,7.55 0.61,7.73 0.58,8.15 0.69,8.35 0.37,8.76 0.49,8.85 0.47,9.12 0.53,9.40 0.41,9.49 0.26,9.43 0.23,9.46 0.34,9.60 0.26,9.64 0.32,9.69 0.38,10.29 -0.09,10.67 0.01,11.02 -0.07,11.12
|
||||||
|
-2.70,9.48 -2.67,9.28 -2.75,9.05 -2.60,8.80 -2.51,8.21 -2.61,8.15 -2.61,8.05 -2.79,7.93 -2.99,7.20 -3.24,6.81 -3.24,6.65 -3.00,5.71 -2.96,5.64 -2.79,5.60 -2.76,5.36 -2.82,5.15 -3.11,5.09
|
||||||
|
-5.52,10.43 -5.46,10.77 -5.49,11.04 -5.35,11.13 -5.25,11.38 -5.29,11.83 -5.11,11.97 -4.70,12.08 -4.43,12.34 -4.42,12.58 -4.48,12.67 -4.23,12.79 -4.33,13.12 -4.15,13.31 -3.95,13.40 -3.53,13.18 -3.30,13.28 -3.25,13.66 -2.95,13.65 -2.87,13.95 -2.59,14.23 -2.46,14.27 -2.11,14.17 -1.97,14.46 -1.70,14.51 -1.05,14.82 -0.76,15.05 -0.54,15.08 -0.41,15.01 -0.24,15.06 0.22,14.91
|
||||||
|
-2.70,9.48 -2.82,9.43 -2.99,9.69 -3.22,9.90 -3.79,9.92 -4.27,9.74 -4.33,9.65 -4.63,9.71 -4.97,9.93 -5.10,10.24 -5.38,10.31 -5.52,10.43
|
||||||
|
-8.49,7.56 -8.30,6.98 -8.33,6.80 -8.60,6.51 -8.29,6.32 -7.89,6.23 -7.80,5.98 -7.45,5.84 -7.40,5.55 -7.43,5.32 -7.57,5.08 -7.59,4.82 -7.54,4.35
|
||||||
|
-8.49,7.56 -8.66,7.69 -8.89,7.26 -9.12,7.22 -9.26,7.38 -9.46,7.42 -9.37,7.70 -9.52,8.35 -9.68,8.48 -9.74,8.45 -9.78,8.54 -10.06,8.43 -10.15,8.52 -10.28,8.49
|
||||||
|
-5.52,10.43 -5.84,10.39 -6.03,10.19 -6.20,10.23 -6.26,10.72 -6.43,10.67 -6.42,10.56 -6.65,10.66 -6.69,10.35 -6.95,10.34 -7.02,10.14 -7.36,10.26 -7.50,10.44 -7.66,10.43 -7.81,10.24 -7.99,10.16
|
||||||
|
-15.04,10.94 -14.68,11.51 -14.27,11.66 -13.73,11.74 -13.74,12.01 -13.95,12.18 -13.71,12.31 -13.67,12.48 -13.73,12.67
|
||||||
|
-11.39,12.40 -11.50,12.20 -11.31,12.02 -10.93,12.21 -10.71,11.90 -10.47,12.14 -10.27,12.21 -9.75,12.03 -9.66,12.14 -9.36,12.26 -9.40,12.46 -9.22,12.48 -9.04,12.40 -8.82,11.92 -8.82,11.67 -8.40,11.37 -8.67,11.01 -8.47,11.05 -8.34,10.99 -8.27,10.49 -8.01,10.32 -7.99,10.16
|
||||||
|
107.52,14.71 107.41,14.56 107.29,14.59 106.94,14.33 106.78,14.34 106.50,14.58 106.35,14.45 106.23,14.48 106.17,14.37 105.98,14.34 106.12,14.05 106.07,13.92 105.90,13.92 105.74,14.08 105.53,14.16 105.35,14.11 105.18,14.35
|
||||||
|
114.06,4.60 114.22,4.48 114.32,4.26 114.42,4.26 114.57,4.05 114.65,4.04 114.78,4.17 114.84,4.39 114.75,4.72 115.03,4.90
|
||||||
|
-88.23,15.73 -88.96,15.15 -89.14,15.07 -89.22,14.87 -89.17,14.61 -89.36,14.42
|
||||||
|
-77.37,8.66 -77.48,8.50 -77.20,7.97 -77.35,7.84 -77.35,7.71 -77.54,7.57 -77.59,7.54 -77.76,7.70 -77.74,7.54 -77.90,7.23
|
||||||
|
-60.74,5.20 -61.39,5.94 -61.13,6.21 -61.20,6.59 -61.15,6.69 -60.87,6.79 -60.72,6.77 -60.35,7.00 -60.35,7.15 -60.58,7.16 -60.63,7.21 -60.61,7.32 -60.72,7.54 -60.51,7.81 -60.38,7.83 -60.03,8.05 -59.96,8.19 -59.85,8.25 -59.83,8.31 -60.02,8.55
|
||||||
|
-60.74,5.20 -60.67,5.16 -60.60,4.95 -61.00,4.54 -61.28,4.52 -61.55,4.29 -61.82,4.20 -62.15,4.10 -62.41,4.16 -62.71,4.02 -62.76,3.67 -62.86,3.59 -62.97,3.59 -63.34,3.94 -63.53,3.89 -64.02,3.93 -64.19,4.13 -64.58,4.14 -64.79,4.28 -64.82,4.23 -64.67,4.01 -64.22,3.59 -64.22,3.20 -64.01,2.67 -64.05,2.50 -63.39,2.41 -63.43,2.16 -64.01,1.93 -64.11,1.62 -64.21,1.53 -64.30,1.46 -64.49,1.45 -64.73,1.25 -65.10,1.11 -65.47,0.69 -65.56,0.69 -65.52,0.84 -65.68,0.98 -66.06,0.79 -66.35,0.77 -66.88,1.22
|
||||||
|
52.49,41.78 53.06,42.15 54.12,42.34 54.85,41.97 55.43,41.30 55.98,41.32
|
||||||
|
55.98,41.32 57.02,41.26 57.12,41.35 57.02,41.45 56.96,41.86 57.29,42.12 57.81,42.19 58.03,42.49 58.28,42.43 58.40,42.29 58.47,42.30 58.15,42.63 58.26,42.69 58.48,42.66 58.59,42.78 58.88,42.56 59.16,42.51 59.35,42.32 59.86,42.30 59.99,42.21 59.94,41.97 60.20,41.80 60.08,41.76 60.14,41.59 60.09,41.40 60.45,41.22 60.87,41.25 61.24,41.19 61.50,41.28 61.90,41.09 62.10,40.68 62.38,40.33 62.48,39.98 63.51,39.38 63.76,39.16 64.16,38.95 64.31,38.98 65.61,38.24 65.97,38.24 66.57,38.01 66.63,37.93 66.53,37.79 66.52,37.35
|
||||||
|
49.23,46.34 48.54,46.61 48.50,46.70 48.56,46.76 48.88,46.71 48.96,46.77 48.55,47.32 48.17,47.71 47.48,47.80 47.29,47.74 47.09,47.95 47.12,48.13 47.06,48.23 46.66,48.41 46.61,48.57 46.70,48.81 47.03,49.15 46.80,49.37 46.89,49.70 46.99,49.85 47.25,50.00 47.33,50.27 47.50,50.40 47.71,50.38 48.33,49.86 48.43,49.83 48.76,49.93 48.84,50.01 48.63,50.61 48.81,50.60 49.32,50.85 49.50,51.08 49.82,51.13 50.31,51.32 50.79,51.73 51.16,51.65 51.34,51.48 51.61,51.48 52.22,51.71 52.57,51.48 53.34,51.48 54.14,51.04 54.42,50.78 54.47,50.58 54.56,50.54 54.65,50.66 54.55,50.95 54.64,51.01 55.36,50.67 55.69,50.58 56.05,50.71 56.14,50.84 56.49,51.02 56.62,50.98 57.01,51.07 57.44,50.89 57.65,50.93 57.84,51.09 58.36,51.06 58.88,50.69 59.45,50.62 59.52,50.58 59.52,50.49 59.81,50.58 59.96,50.80 60.06,50.85 60.42,50.68 60.94,50.70 61.39,50.86 61.59,51.23 61.55,51.32 61.36,51.44 60.46,51.65 60.39,51.77 60.03,51.93 60.43,52.13 60.67,52.15 60.99,52.34 60.77,52.68 61.05,52.97 61.97,52.94 62.08,53.01 62.01,53.11 61.66,53.23 61.20,53.29 61.16,53.34 61.23,53.45 61.53,53.52 61.47,53.58 61.25,53.55 60.98,53.62 61.11,53.75 61.14,53.96 61.23,54.02 61.33,54.05 61.93,53.95 63.85,54.24 64.46,54.38 64.93,54.40 65.09,54.34 65.24,54.52 65.48,54.62 65.71,54.62 65.91,54.69 66.22,54.67 68.16,54.98 68.24,55.05 68.21,55.16 68.52,55.20 68.98,55.39 69.49,55.36 70.18,55.16 70.49,55.28 70.74,55.31 70.91,55.13 71.19,54.60 71.15,54.36 71.05,54.26 71.09,54.21 71.34,54.16 72.00,54.21 72.19,54.33 72.39,54.12 72.45,53.94 72.59,54.00 72.56,54.09 72.62,54.13 72.91,54.11 73.23,53.96 73.62,54.07 73.71,54.04 73.68,53.93 73.40,53.81 73.31,53.71 73.29,53.60 73.41,53.45 73.86,53.62 74.21,53.58 74.35,53.49 74.45,53.65 74.83,53.83 75.22,53.89 75.38,53.97 75.44,54.09 76.84,54.44 76.65,54.15 76.42,54.15 76.48,54.02 77.86,53.27 79.47,51.49 79.99,50.77 80.07,50.76 80.09,50.84 80.22,50.91 80.42,50.95 80.45,51.18 80.74,51.29 80.88,51.28 80.97,51.19 81.13,51.19 81.07,50.97 81.39,50.96 81.47,50.74 82.49,50.73 82.76,50.89 83.36,50.99 83.95,50.77 84.18,50.52 84.32,50.24 84.99,50.06 85.00,49.89 85.23,49.62 86.18,49.50 86.68,49.78 86.73,49.70 86.63,49.56 86.81,49.49 87.00,49.29 87.23,49.22 87.32,49.09
|
||||||
|
34.20,31.32 34.90,29.48
|
||||||
|
33.98,4.22 34.38,4.62
|
||||||
|
41.88,3.98 41.14,3.96 40.77,4.27 39.84,3.85 39.54,3.47 39.23,3.48 38.09,3.65 36.91,4.41 36.08,4.45 35.92,4.62
|
||||||
|
23.38,-17.64 21.42,-18.00 21.11,-17.96 20.75,-18.02 20.39,-17.89 18.96,-17.80 18.72,-17.70 18.40,-17.40 14.02,-17.41 13.48,-17.04 13.10,-16.97 12.55,-17.21 12.01,-17.17 11.74,-17.25
|
||||||
|
25.26,-17.79 25.64,-17.82 26.00,-17.97 26.14,-17.91 26.33,-17.93 26.78,-18.04 27.02,-17.96 27.93,-16.90 28.76,-16.53 28.83,-16.42 28.91,-15.99 29.49,-15.70 30.40,-15.64
|
||||||
|
23.38,-17.64 24.27,-17.48 24.73,-17.52 25.00,-17.57 25.26,-17.79
|
||||||
|
31.29,-22.40 32.43,-21.30 32.35,-21.14 32.48,-20.95 32.49,-20.66 32.67,-20.52 32.99,-19.98 33.01,-19.87 32.78,-19.39 32.85,-19.10 32.70,-18.94 32.72,-18.83 32.88,-18.73 32.99,-18.36 32.97,-17.25 32.88,-16.88 32.95,-16.71 32.24,-16.45 31.94,-16.43 31.69,-16.21 31.49,-16.18 31.24,-16.02 30.44,-16.00 30.40,-15.64
|
||||||
|
12.28,-6.00 12.42,-6.05 13.18,-5.86 16.43,-5.90 16.70,-6.16 16.74,-6.62 16.92,-6.93 16.98,-7.26 17.58,-8.10 18.01,-8.11 18.56,-7.94 18.94,-8.00 19.34,-7.97 19.53,-7.14 19.66,-7.04 19.88,-6.99 20.59,-6.92 20.54,-7.18 20.61,-7.28 21.78,-7.31 21.84,-7.42 21.80,-8.11 21.90,-8.34 21.91,-8.69 21.81,-9.47 22.20,-10.04 22.27,-10.26 22.31,-10.69 22.18,-10.89 22.23,-11.12 22.31,-11.20 22.56,-11.06 23.08,-11.09 23.46,-10.97 23.83,-11.01 23.97,-10.87
|
||||||
|
-16.56,13.59 -15.51,13.59 -15.43,13.73 -15.11,13.81 -14.94,13.79 -14.41,13.50 -13.98,13.54 -13.83,13.41 -13.85,13.34 -14.25,13.24 -15.15,13.56 -15.29,13.40 -15.81,13.33 -15.83,13.16 -16.65,13.15 -16.76,13.06
|
||||||
|
0.22,14.91 0.16,14.50 0.38,14.25 0.35,14.14 0.43,13.97 0.62,13.70 0.90,13.61 1.20,13.36 0.99,13.36 0.99,13.04 1.56,12.64 1.79,12.61 1.96,12.71 2.10,12.70 2.23,12.47 2.07,12.38 2.07,12.31 2.39,11.90
|
||||||
|
18.61,3.48 18.47,3.62 18.16,3.50 17.49,3.69 16.61,3.51 16.48,3.17 16.47,2.83 16.18,2.27
|
||||||
|
16.18,2.26 16.08,2.11 16.14,1.72 16.06,1.68 15.74,1.92 15.34,1.94 15.16,2.04 14.90,2.01 14.88,2.08 14.76,2.08 14.58,2.20 14.48,2.15 13.29,2.16
|
||||||
|
15.48,7.52 15.21,7.21 14.74,6.28 14.51,6.16 14.43,6.04 14.62,5.87 14.56,5.28 14.64,5.18 14.73,4.60 15.06,4.28 15.14,4.04 15.03,4.02 15.13,3.83 15.77,3.13 15.90,3.10 16.06,2.91 16.18,2.26
|
||||||
|
33.98,4.22 33.49,3.76 33.15,3.77 33.00,3.88 32.34,3.71 32.14,3.52 31.94,3.61 31.80,3.80 31.55,3.68 31.15,3.79 30.93,3.63 30.84,3.49
|
||||||
|
13.07,-4.63 12.83,-4.74 12.45,-5.07 12.52,-5.15 12.50,-5.70 12.21,-5.76
|
||||||
|
31.95,-25.96 31.99,-24.46 31.80,-23.89 31.55,-23.48 31.29,-22.40
|
||||||
|
33.20,-14.01 30.23,-14.99 30.40,-15.64
|
||||||
|
34.96,-11.58 34.62,-11.62 34.36,-12.16 34.56,-13.36 34.66,-13.49 34.91,-13.55 35.25,-13.90 35.85,-14.67 35.89,-14.89 35.81,-15.27 35.82,-15.68 35.76,-16.06 35.36,-16.16 35.17,-16.56 35.28,-16.81 35.27,-17.12 35.06,-17.08 35.08,-16.83 34.93,-16.76 34.42,-16.25 34.40,-16.08 34.25,-15.89 34.54,-15.30 34.51,-14.60 34.33,-14.41 33.64,-14.57 33.20,-14.01
|
||||||
|
29.58,-1.39 29.56,-0.98 29.72,0.10 29.93,0.50 29.94,0.82 30.18,0.97 30.32,1.19 30.48,1.24 31.25,2.04 31.27,2.15 31.18,2.27 30.96,2.40 30.73,2.46 30.85,2.89 30.75,3.04 30.91,3.41 30.84,3.49
|
||||||
|
36.52,14.26 36.43,15.13 36.52,15.25 36.91,16.30 36.89,16.62 36.98,16.80 37.01,17.06 37.41,17.06 37.55,17.32 37.78,17.46 38.25,17.58 38.61,18.01
|
||||||
|
42.38,12.47 42.13,12.77 41.95,12.88 41.77,13.18 41.36,13.50 40.82,14.11 40.14,14.46 39.90,14.44 39.53,14.54 39.20,14.48 39.02,14.63 38.81,14.48 38.43,14.43 38.22,14.65 37.88,14.85 37.57,14.15 37.26,14.45 37.02,14.27 36.81,14.32 36.52,14.26
|
||||||
|
27.35,57.53 27.40,57.67 27.54,57.80 27.78,57.87 27.67,57.93 27.50,58.22 27.53,58.44 27.43,58.79 27.76,59.05 27.90,59.28 28.15,59.37 28.01,59.48
|
||||||
|
74.31,32.81 74.66,32.76 74.69,32.49 74.99,32.46 75.30,32.32
|
||||||
|
-84.35,10.98 -84.70,11.05 -84.91,10.95 -85.58,11.19 -85.74,11.06
|
||||||
|
-82.88,8.07 -83.03,8.34 -82.86,8.45 -82.86,8.64 -82.92,8.74 -82.73,8.92 -82.94,9.06 -82.94,9.45 -82.80,9.59 -82.64,9.51 -82.56,9.58
|
||||||
|
-66.88,1.22 -67.21,2.39 -67.62,2.79 -67.86,2.79 -67.83,2.89 -67.31,3.42 -67.66,3.86 -67.86,4.51 -67.82,5.27 -67.64,5.56 -67.58,5.83 -67.47,5.93 -67.48,6.18 -67.86,6.29 -68.47,6.16 -68.94,6.20 -69.27,6.10 -69.43,6.12 -70.13,6.95 -70.74,7.09 -71.13,6.99 -72.01,7.03 -72.21,7.37 -72.39,7.42 -72.47,7.52 -72.45,7.97 -72.36,8.09 -72.39,8.29 -72.67,8.63 -72.80,9.11 -72.96,9.14 -73.06,9.26 -73.37,9.19 -73.01,9.79 -72.87,10.49 -72.69,10.84 -72.45,11.11 -72.25,11.20 -71.96,11.67 -71.32,11.86
|
||||||
|
-68.44,-52.36 -68.46,-52.29 -69.96,-52.01 -71.97,-51.96 -71.95,-51.88 -72.41,-51.54 -72.30,-51.30 -72.38,-51.10 -72.28,-50.91 -72.34,-50.68 -72.51,-50.61 -73.15,-50.74 -73.31,-50.36 -73.50,-50.13 -73.48,-49.77
|
||||||
|
-68.63,-52.65 -68.65,-54.92 -68.06,-54.88 -67.35,-54.93 -66.48,-55.11
|
||||||
|
22.77,54.36 19.60,54.46
|
||||||
|
48.44,28.54 47.67,28.53 47.43,28.99 46.53,29.10
|
||||||
|
44.77,37.14 44.88,36.80 45.02,36.70 45.05,36.47 45.24,36.36 45.36,36.02 45.56,35.98 45.78,35.82 46.27,35.77 46.00,35.61 45.98,35.48 46.11,35.32 46.13,35.13 45.92,35.03 45.68,34.80 45.64,34.57 45.50,34.58 45.44,34.42 45.54,34.22 45.40,33.97 45.74,33.60 45.88,33.61 45.87,33.49 46.02,33.42 46.15,33.23 46.08,33.09 46.11,32.96 46.38,32.93 47.12,32.47 47.37,32.42 47.51,32.15 47.83,31.79 47.68,31.40 47.68,31.00 48.01,30.99 48.01,30.47 48.33,30.29 48.43,30.04 48.55,29.96
|
||||||
|
61.26,35.62 61.25,35.87 61.15,35.98 61.21,36.19 61.12,36.64 60.34,36.64 60.06,36.96 59.45,37.25 59.30,37.51 58.82,37.68 58.26,37.67 57.98,37.83 57.35,37.97 57.31,38.13 57.19,38.22 56.44,38.25 56.32,38.19 56.27,38.08 55.58,38.10 55.38,38.05 54.90,37.78 54.70,37.47 54.19,37.33 53.91,37.34
|
||||||
|
140.97,-2.60 140.97,-6.35 140.86,-6.74 140.98,-6.91 140.98,-9.12
|
||||||
|
9.52,30.23 9.31,30.12 9.64,29.64 9.81,29.18 9.92,27.79 9.75,27.33 9.89,26.85 9.88,26.63 9.86,26.55 9.49,26.33 9.45,26.07 10.00,25.33 10.03,25.05 10.26,24.59 10.40,24.49 10.69,24.55 11.51,24.31 11.97,23.52
|
||||||
|
-13.73,12.67 -15.20,12.68 -15.57,12.49 -16.71,12.35
|
||||||
|
-11.39,12.40 -12.40,12.34 -12.93,12.53 -13.06,12.49 -13.08,12.63 -13.73,12.67
|
||||||
|
0.90,10.99 0.49,10.95 -0.07,11.12
|
||||||
|
-139.06,60.00 -139.19,60.08 -139.08,60.34 -139.68,60.33 -139.97,60.18 -140.45,60.30 -140.53,60.22 -141.00,60.30 -141.00,69.65
|
||||||
|
-130.62,54.71 -130.21,55.03 -130.02,55.28 -130.12,55.69 -130.03,55.89 -130.06,56.07 -130.41,56.12 -130.48,56.23 -130.74,56.34 -131.58,56.60 -131.82,56.59 -131.87,56.79 -132.10,56.86 -132.03,57.03 -132.34,57.08 -132.23,57.20 -133.42,58.34 -133.40,58.41 -133.82,58.71 -134.30,58.90 -134.44,59.09 -134.94,59.29 -135.07,59.44 -135.05,59.58 -135.48,59.79 -136.32,59.60 -136.25,59.53 -136.35,59.46 -136.47,59.46 -136.47,59.28 -136.58,59.15 -136.81,59.15 -137.44,58.90 -137.52,58.92 -137.48,58.99 -137.59,59.23 -138.63,59.78 -138.71,59.90 -139.06,60.00
|
||||||
|
-123.31,48.99 -122.69,48.99
|
||||||
|
-88.89,15.89 -89.24,15.89 -89.16,17.81
|
||||||
|
-75.28,-0.11 -75.48,-0.16 -75.63,-0.12 -75.26,-0.59 -75.25,-0.95 -75.41,-0.92 -75.57,-1.53 -76.09,-2.13 -76.68,-2.56 -77.86,-2.98 -78.18,-3.35 -78.16,-3.47 -78.23,-3.49 -78.35,-3.40 -78.42,-3.78 -78.68,-4.33 -78.69,-4.56 -78.91,-4.71 -78.92,-4.86 -79.03,-4.97 -79.33,-4.93 -79.50,-4.67 -79.52,-4.54 -79.64,-4.45 -79.80,-4.48 -80.14,-4.30 -80.38,-4.46 -80.48,-4.43 -80.35,-4.21 -80.49,-4.17 -80.49,-4.01 -80.30,-4.01 -80.18,-3.88 -80.32,-3.39
|
||||||
|
-69.97,-4.24 -70.34,-3.81 -70.53,-3.87 -70.74,-3.78 -70.07,-2.75 -70.10,-2.66 -70.97,-2.21 -71.40,-2.33 -71.75,-2.15 -71.98,-2.33 -72.22,-2.40 -72.40,-2.43 -72.63,-2.35 -72.94,-2.39 -73.15,-2.28 -73.13,-2.08 -73.20,-1.83 -73.50,-1.69 -73.52,-1.45 -73.66,-1.25 -74.25,-0.97 -74.42,-0.58 -74.80,-0.20 -74.95,-0.19 -75.14,-0.05 -75.28,-0.11
|
||||||
|
-66.88,1.22 -67.08,1.19 -67.12,1.70 -67.40,2.12 -67.56,2.07 -67.82,1.79 -67.94,1.75 -68.19,1.99 -68.26,1.85 -68.18,1.72 -69.32,1.72 -69.54,1.77 -69.85,1.71 -69.85,1.06 -69.31,1.05 -69.16,0.86 -69.15,0.66 -69.28,0.63 -69.47,0.73 -70.05,0.58 -70.07,-0.14 -69.92,-0.32 -69.63,-0.51 -69.61,-0.76 -69.45,-1.00 -69.40,-1.19 -69.97,-4.24
|
||||||
|
-69.58,-10.95 -68.69,-12.50 -68.76,-12.69 -68.98,-12.88 -68.98,-13.50 -69.07,-13.68 -68.87,-14.17 -69.00,-14.27 -69.01,-14.38 -69.36,-14.80 -69.37,-14.96 -69.17,-15.24 -69.42,-15.64 -69.22,-16.15 -68.84,-16.34 -69.03,-16.48 -69.02,-16.64 -69.20,-16.77 -69.44,-17.09 -69.62,-17.20 -69.51,-17.50
|
||||||
|
-69.58,-10.95 -68.85,-11.01 -68.73,-11.12 -68.62,-11.11 -68.31,-10.98 -68.07,-10.70 -67.72,-10.68 -67.42,-10.39 -67.11,-10.27 -66.58,-9.90 -65.92,-9.79 -65.56,-9.80 -65.40,-9.71 -65.31,-9.87 -65.30,-10.15 -65.45,-10.51 -65.32,-11.02 -65.39,-11.25 -65.18,-11.65 -65.19,-11.75 -65.09,-11.74 -64.99,-11.98 -64.51,-12.25 -64.42,-12.44 -63.94,-12.53 -63.69,-12.48 -63.35,-12.68 -63.07,-12.67 -63.02,-12.81 -62.77,-13.00 -62.12,-13.16 -61.79,-13.53 -61.08,-13.49 -60.51,-13.79 -60.40,-14.02 -60.47,-14.18 -60.30,-14.62 -60.27,-15.09 -60.58,-15.10 -60.24,-15.48 -60.18,-16.27 -58.54,-16.33 -58.35,-16.28 -58.35,-16.49 -58.48,-16.70 -58.40,-17.23 -57.99,-17.51 -57.83,-17.51 -57.59,-18.12 -57.50,-18.21 -57.57,-18.28 -57.78,-18.91 -57.73,-18.92 -57.72,-19.04 -57.80,-19.08 -58.13,-19.74 -57.86,-19.98 -58.16,-20.16
|
||||||
|
-69.97,-4.24 -70.05,-4.33 -70.24,-4.30 -70.40,-4.15 -70.80,-4.17 -70.97,-4.35 -71.84,-4.50 -72.35,-4.79 -72.70,-5.07 -72.89,-5.12 -72.98,-5.63 -73.24,-6.10 -73.14,-6.47 -73.76,-6.91 -73.80,-7.08 -73.72,-7.31 -73.96,-7.38 -74.00,-7.56 -73.72,-7.78 -73.78,-7.94 -73.64,-8.07 -73.55,-8.35 -73.36,-8.48 -73.30,-8.65 -72.97,-8.99 -72.97,-9.12 -73.21,-9.41 -72.81,-9.41 -72.38,-9.51 -72.17,-9.84 -72.14,-10.01 -71.24,-9.97 -70.54,-9.44 -70.64,-9.82 -70.64,-11.01 -70.53,-10.95 -70.29,-11.06 -69.96,-10.93 -69.58,-10.95
|
||||||
|
-62.65,-22.23 -62.28,-21.07 -62.28,-20.56 -61.92,-20.06 -61.76,-19.65 -60.01,-19.30 -59.09,-19.29 -58.18,-19.82 -58.16,-20.16
|
||||||
|
-17.05,20.81 -16.96,21.33 -13.02,21.33 -13.15,22.82 -13.03,23.00 -12.62,23.27 -12.02,23.47 -12.02,26.00 -8.68,26.00 -8.68,27.29
|
||||||
|
-8.68,27.29 -4.82,25.00
|
||||||
|
-8.68,27.66 -8.66,28.72 -7.69,29.35 -7.49,29.39 -7.16,29.61 -6.64,29.57 -6.52,29.66 -6.48,29.82 -6.00,29.83 -5.45,29.96 -5.18,30.17 -4.97,30.47 -4.32,30.70 -3.99,30.91 -3.67,30.96 -3.62,31.07 -3.83,31.20 -3.79,31.36 -3.83,31.66 -3.02,31.83 -2.86,32.07 -2.45,32.13 -1.28,32.09
|
||||||
|
-8.68,27.29 -8.68,27.66
|
||||||
|
-12.28,14.81 -12.86,15.24 -12.93,15.45 -13.21,15.62 -13.41,16.06 -13.51,16.14 -13.87,16.15 -14.09,16.42 -14.30,16.58 -14.53,16.66 -14.99,16.68 -15.77,16.49 -16.24,16.53 -16.44,16.20 -16.54,15.84
|
||||||
|
-4.82,25.00 -6.59,24.99 -5.63,16.57 -5.36,16.28 -5.51,15.50 -9.18,15.50 -9.34,15.53 -9.35,15.68 -9.43,15.62 -9.45,15.46 -9.94,15.37 -10.70,15.42 -10.95,15.15 -11.50,15.64 -11.76,15.43 -11.94,14.89 -12.10,14.75 -12.28,14.81
|
||||||
|
4.23,19.14 3.40,18.99 3.26,19.01 3.11,19.15 3.26,19.41 3.20,19.79 3.13,19.85 2.41,20.06 2.22,20.25 1.75,20.33 1.61,20.56 1.17,20.82 1.15,21.10 -4.82,25.00
|
||||||
|
4.23,19.14 5.84,19.48 7.48,20.87 11.97,23.52
|
||||||
|
9.52,30.23 9.04,32.07 8.33,32.54 8.11,33.06 7.73,33.27 7.50,33.83 7.51,34.08 7.75,34.25 7.84,34.41 8.12,34.56 8.25,34.73 8.28,34.98 8.39,35.20 8.25,35.80 8.35,36.37 8.21,36.52 8.44,36.76 8.60,36.83 8.58,36.94
|
||||||
|
23.98,19.50 15.98,23.45 14.98,23.00
|
||||||
|
24.98,22.00 24.98,20.00 23.98,20.00 23.98,19.50
|
||||||
|
25.15,31.65 24.85,31.33 24.96,30.68 24.70,30.20 24.98,29.18 24.98,22.00
|
||||||
|
22.86,10.92 22.92,11.34 22.59,11.58 22.58,11.99 22.47,12.07 22.35,12.66 22.23,12.71 21.93,12.68 21.83,12.79 21.99,13.11 22.23,13.33 22.11,13.80 22.17,13.91 22.54,14.16 22.38,14.55 22.67,14.72 22.76,15.00 22.93,15.16 22.93,15.53 23.11,15.70 23.97,15.72 23.98,19.50
|
||||||
|
24.98,22.00 31.21,21.99 31.40,22.20 31.49,22.15 31.43,22.00 36.87,22.00
|
||||||
|
41.88,3.98 41.34,3.20 40.96,2.81 40.98,-0.87 41.52,-1.57 41.53,-1.70
|
||||||
|
42.92,11.00 42.66,10.60 42.84,10.20 43.07,9.93 43.18,9.88 43.48,9.38 43.62,9.34 43.98,9.01 46.98,8.00 47.98,8.00
|
||||||
|
30.75,-8.19 28.90,-8.49 28.93,-8.59 28.87,-8.79 28.40,-9.22 28.60,-9.68 28.65,-10.55 28.36,-11.48 28.38,-11.57 28.48,-11.81 29.06,-12.35 29.49,-12.42 29.51,-12.23 29.80,-12.16 29.78,-13.44 29.65,-13.41 29.63,-13.30 29.55,-13.25 29.20,-13.40 29.01,-13.37 28.73,-12.93 28.55,-12.84 28.41,-12.52 27.57,-12.23 27.42,-11.94 27.24,-11.78 27.16,-11.58 27.05,-11.62 26.95,-11.90 26.82,-11.97 26.03,-11.89 25.51,-11.75 25.35,-11.62 25.29,-11.21 24.52,-11.44 24.38,-11.42 24.34,-11.37 24.40,-11.26 24.37,-11.13 24.14,-11.03 24.08,-10.89 23.97,-10.87
|
||||||
|
23.38,-17.64 22.15,-16.60 22.04,-16.26 21.98,-15.96 21.98,-13.00 23.96,-12.99 23.88,-12.80 23.99,-12.42 23.97,-11.64 24.05,-11.41 23.97,-10.87
|
||||||
|
33.90,-1.00 33.94,0.17 34.16,0.61 34.41,0.87 34.48,1.04 34.80,1.24 34.78,1.38 34.97,1.64 34.98,1.77 34.91,2.48 34.74,2.82 34.59,2.92 34.52,3.12 34.45,3.16 34.40,3.41 34.44,3.65 34.17,3.81 34.19,3.87 33.98,4.22
|
||||||
|
33.90,-1.00 37.64,-3.05 37.69,-3.25 37.62,-3.51 39.22,-4.69
|
||||||
|
11.33,2.17 9.98,2.17 9.80,2.30
|
||||||
|
11.33,2.17 11.34,1.00 10.03,1.00 9.91,0.96 9.76,1.07 9.59,1.03
|
||||||
|
25.26,-17.79 24.91,-17.82 24.53,-18.05 24.36,-17.98 24.24,-18.02 23.60,-18.46 23.30,-18.03 23.22,-18.00 20.97,-18.32 20.98,-21.96 19.98,-22.00 19.98,-24.75
|
||||||
|
19.98,-24.78 19.98,-28.45 19.54,-28.57 19.31,-28.73 19.25,-28.90 19.16,-28.94 18.84,-28.87 18.10,-28.87 17.45,-28.70 17.35,-28.50 17.36,-28.27 17.06,-28.03 16.88,-28.13 16.76,-28.45 16.45,-28.62
|
||||||
|
19.98,-24.75 19.98,-24.78
|
||||||
|
0.90,10.99 0.79,10.71 0.76,10.39 1.33,10.00 1.39,9.36 1.60,9.05 1.62,7.00 1.53,6.99 1.60,6.61 1.78,6.29 1.62,6.22
|
||||||
|
42.80,16.37 43.17,16.69 43.18,16.81 43.12,16.94 43.16,17.21 43.24,17.27 43.19,17.36 43.42,17.52 43.92,17.32 44.16,17.40 45.15,17.43 45.54,17.30 46.31,17.23 46.73,17.27 46.98,16.95 47.14,16.95 47.44,17.11 47.58,17.45 48.17,18.16 49.04,18.58 51.98,19.00
|
||||||
|
51.98,19.00 52.73,17.30 52.80,17.27 53.09,16.65
|
||||||
|
55.19,22.70 55.64,22.00 54.98,20.00 51.98,19.00
|
||||||
|
55.19,22.70 55.10,22.62 52.56,22.93 51.59,24.08 51.57,24.29
|
||||||
|
42.36,37.11 41.79,36.60 41.42,36.51 41.30,36.38 41.25,36.07 41.35,35.64 41.22,35.29 41.19,34.77 40.99,34.43 40.69,34.33 38.77,33.37
|
||||||
|
35.79,32.73 36.37,32.39 36.82,32.32 38.77,33.37
|
||||||
|
35.61,32.68 35.79,32.73
|
||||||
|
39.15,32.12 39.29,32.24 39.25,32.35 39.04,32.31 38.98,32.47 39.06,32.49 38.77,33.37
|
||||||
|
34.95,29.35 36.07,29.20 36.48,29.50 36.76,29.87 37.47,30.00 37.65,30.33 37.98,30.50 36.96,31.49 38.96,31.99 39.15,32.12
|
||||||
|
46.53,29.10 46.36,29.06 44.69,29.20 42.07,31.08 40.37,31.94 39.15,32.12
|
||||||
|
61.59,25.20 61.66,25.75 61.75,25.84 61.84,26.23 62.13,26.37 62.24,26.36 62.31,26.49 62.44,26.56 63.16,26.65 63.30,27.15 63.17,27.25 62.76,27.25 62.81,27.50 62.76,28.24 62.56,28.24 62.35,28.41 61.89,28.55 61.62,28.79 61.32,29.37 60.84,29.86
|
||||||
|
60.84,29.86 61.81,30.91 61.76,31.29 61.66,31.38 60.82,31.50 60.83,32.25 60.56,33.06 60.56,33.14 60.92,33.51 60.51,33.64 60.49,34.09 60.64,34.31 60.89,34.32 60.73,34.52 60.95,34.65 61.08,34.86 61.15,35.09 61.10,35.27 61.19,35.31 61.28,35.51 61.26,35.62
|
||||||
|
73.73,36.89 74.04,36.83 74.54,37.02
|
||||||
|
60.84,29.86 62.48,29.41 63.57,29.50 64.10,29.39 64.39,29.54 65.10,29.56 66.18,29.84 66.31,29.97 66.24,30.11 66.35,30.80 66.40,30.91 66.60,31.02 66.83,31.26 66.92,31.31 67.45,31.23 67.74,31.34 67.58,31.51 68.02,31.68 68.16,31.80 68.44,31.75 68.60,31.80 68.87,31.63 69.08,31.74 69.28,31.94 69.24,32.43 69.41,32.68 69.50,33.02 69.92,33.11 70.26,33.29 70.28,33.37 70.13,33.62 69.87,33.90 69.89,34.01 69.99,34.05 70.33,33.96 70.65,33.95 71.05,34.05 71.10,34.37 70.97,34.53 71.62,35.18 71.55,35.29 71.60,35.41 71.57,35.55 71.40,35.88 71.19,36.04 71.23,36.12 71.62,36.44 71.77,36.43 72.25,36.73 72.62,36.83 73.73,36.89
|
||||||
|
70.95,42.25 70.72,42.17 70.58,42.04 70.42,42.08 70.10,41.82 69.15,41.43 68.99,41.21 68.58,40.88 68.57,40.62 68.42,40.62 68.05,40.81 68.11,41.03 67.94,41.20 66.71,41.18 66.50,41.99 66.01,42.00 66.10,42.99 65.80,42.88 65.50,43.31 64.91,43.71 64.44,43.55 63.21,43.63 61.99,43.49 61.16,44.17 61.01,44.39 58.56,45.56 55.98,44.99 55.98,41.32
|
||||||
|
13.61,13.70 13.45,14.38 14.37,15.75 15.47,16.91 15.74,19.90 15.96,20.35 15.59,20.73 15.54,20.87 15.61,20.95 15.18,21.52 15.17,21.92 14.98,23.00
|
||||||
|
11.97,23.52 13.48,23.18 14.22,22.62 14.98,23.00
|
||||||
|
0.22,14.91 0.29,14.98 0.95,14.98 1.30,15.27 3.00,15.34 3.06,15.43 3.50,15.36 3.52,15.48 3.84,15.70 4.12,16.36 4.23,17.00 4.23,19.14
|
||||||
|
-0.07,11.12 -0.30,11.17 -0.63,10.93 -0.70,10.99 -1.04,11.01 -2.83,11.00 -2.91,10.59 -2.79,10.43 -2.78,9.75 -2.70,9.48
|
||||||
|
-13.29,9.05 -13.03,9.10 -12.96,9.26 -12.76,9.37 -12.50,9.86 -12.43,9.90 -12.28,9.93 -12.14,9.88 -11.92,9.92 -11.91,9.99 -11.27,10.00 -10.69,9.31 -10.75,9.10 -10.62,9.06 -10.61,8.87 -10.50,8.69 -10.71,8.34 -10.56,8.32 -10.39,8.48 -10.28,8.49
|
||||||
|
5.79,49.54 6.01,49.45 6.18,49.50 6.34,49.45
|
||||||
|
56.08,26.06 56.17,26.05 56.14,25.69 56.30,25.65
|
||||||
|
30.51,-1.07 30.36,-1.07 30.10,-1.37 29.93,-1.47 29.83,-1.34 29.58,-1.39
|
||||||
|
30.51,-1.07 30.51,-1.21 30.81,-1.56 30.88,-2.14 30.83,-2.34 30.55,-2.40
|
||||||
|
33.90,-1.00 30.81,-0.99 30.51,-1.07
|
||||||
|
34.59,-11.97 34.54,-12.02 34.62,-12.07 34.59,-11.97
|
||||||
|
87.32,49.09 87.82,49.16
|
||||||
|
45.50,40.66 45.48,40.61 45.55,40.62 45.50,40.66
|
||||||
|
44.99,41.09 45.00,41.02 44.99,41.09
|
||||||
|
12.44,43.98 12.40,43.93 12.49,43.90 12.50,43.99 12.44,43.98
|
||||||
|
70.57,41.02 70.65,40.94 70.57,41.02
|
||||||
|
-117.13,32.53 -114.72,32.72 -114.84,32.51 -111.04,31.32 -108.21,31.33 -108.21,31.78 -106.45,31.77 -106.15,31.45 -104.92,30.58 -104.68,30.13 -104.62,29.85 -104.40,29.57 -103.26,29.00 -103.09,29.04 -102.89,29.22 -102.73,29.64 -102.34,29.86 -101.99,29.80 -101.55,29.81 -101.38,29.74 -100.75,29.18 -100.33,28.50 -100.30,28.33 -99.75,27.73 -99.51,27.55 -99.46,27.06 -99.23,26.76 -99.11,26.45 -98.28,26.11 -97.80,26.04 -97.38,25.87 -97.15,25.96
|
||||||
|
-92.24,14.55 -92.16,14.69 -92.16,14.96 -92.07,15.07 -92.20,15.28 -91.74,16.07 -90.45,16.07 -90.42,16.39 -90.63,16.51 -90.71,16.71 -91.41,17.26 -90.99,17.25 -90.99,17.82 -89.16,17.81 -89.13,17.97 -89.05,18.00 -88.90,17.91 -88.81,17.97 -88.46,18.48 -88.04,18.42 -88.04,18.20 -87.83,18.18
|
||||||
|
-54.62,-25.58 -54.61,-25.43 -54.44,-25.12 -54.28,-24.31 -54.32,-24.13 -54.24,-24.05 -54.63,-23.81 -55.19,-24.02 -55.42,-23.95 -55.65,-22.89 -55.62,-22.67 -55.85,-22.31 -56.25,-22.26 -56.45,-22.08 -56.63,-22.23 -56.94,-22.27 -57.57,-22.18 -57.72,-22.10 -57.96,-22.11 -57.95,-21.49 -57.83,-21.00 -58.16,-20.16
|
||||||
|
-62.65,-22.23 -62.21,-22.61 -61.80,-23.18 -61.21,-23.56 -61.03,-23.76 -60.51,-23.96 -59.89,-24.09 -59.19,-24.56 -58.37,-24.96 -57.96,-25.05 -57.64,-25.33 -57.57,-25.53 -57.75,-25.70 -57.89,-26.01 -58.11,-26.18 -58.20,-26.38 -58.19,-26.63 -58.32,-26.86 -58.62,-27.13 -58.60,-27.31 -58.17,-27.27 -57.11,-27.47 -56.60,-27.47 -56.44,-27.55 -56.16,-27.32 -55.71,-27.41 -55.43,-27.01 -55.14,-26.93 -54.83,-26.65 -54.68,-26.31 -54.62,-25.58
|
||||||
|
-62.65,-22.23 -62.84,-22.00 -63.92,-22.03 -64.27,-22.60 -64.33,-22.83 -64.61,-22.23 -64.99,-22.11 -65.77,-22.10 -66.22,-21.80 -66.37,-22.11 -66.71,-22.22 -66.80,-22.41 -67.03,-22.55 -67.19,-22.82
|
||||||
|
26.62,48.26 26.85,48.39 27.23,48.37 27.55,48.48 27.82,48.42 28.09,48.26 28.29,48.24 28.46,48.09 28.53,48.15 28.77,48.12 28.92,47.95 29.13,47.96 29.21,47.78 29.13,47.49 29.54,47.27 29.51,47.13 29.57,46.96 29.88,46.83 29.94,46.72 29.92,46.54 30.13,46.42 29.84,46.35 29.71,46.45 29.61,46.40 29.30,46.47 29.20,46.38 29.19,46.52 28.96,46.46 28.93,46.36 29.01,46.18 28.95,46.05 28.74,45.94 28.73,45.85 28.49,45.67 28.50,45.52 28.21,45.45
|
||||||
|
67.76,37.17 67.96,36.97 68.07,36.95 68.91,37.33 69.30,37.12 69.41,37.21 69.40,37.40 69.49,37.55 69.82,37.61 70.04,37.55 70.19,37.58 70.25,37.66 70.21,37.92 70.62,38.33 70.88,38.46 71.26,38.31 71.33,38.17 71.28,37.92 71.58,37.91 71.43,37.13 71.53,36.85 71.67,36.70 71.80,36.69 72.36,36.98 72.66,37.03 72.90,37.27 73.38,37.46 73.72,37.42 73.65,37.24 74.17,37.33 74.35,37.42 74.66,37.39 74.89,37.23
|
||||||
|
66.52,37.35 66.83,37.37 67.07,37.33 67.20,37.24 67.52,37.27 67.76,37.17
|
||||||
|
61.26,35.62 61.34,35.63 61.62,35.43 61.98,35.44 62.31,35.17 62.46,35.25 62.69,35.26 63.06,35.45 63.17,35.68 63.13,35.85 64.01,36.01 64.51,36.34 64.82,37.13 65.09,37.24 65.55,37.25 65.64,37.47 65.77,37.57 66.11,37.41 66.52,37.35
|
||||||
|
92.57,21.98 92.49,22.69 92.36,22.93 92.25,23.68 91.93,23.69 91.94,23.50 91.75,23.29 91.75,23.05 91.62,22.98 91.51,23.03 91.44,23.20 91.37,23.20 91.37,23.07 91.32,23.10 91.16,23.66 91.23,23.92 91.37,24.09 91.88,24.20 91.95,24.36 92.10,24.41 92.23,24.88 92.44,24.85 92.47,24.94 92.05,25.17 90.44,25.16 89.83,25.29 89.82,25.94 89.67,26.21 89.59,26.19 89.55,26.01 89.37,26.01 89.19,26.11 89.02,26.41 88.92,26.38 88.97,26.25 88.83,26.25 88.68,26.29 88.62,26.43 88.42,26.57 88.35,26.50 88.44,26.37 88.15,26.09 88.08,25.89 88.50,25.54 88.77,25.49 88.95,25.26 88.82,25.18 88.46,25.19 88.31,24.88 88.15,24.91 88.02,24.63 88.15,24.49 88.29,24.48 88.72,24.27 88.70,24.00 88.57,23.67 88.74,23.44 88.72,23.25 88.93,23.19 88.85,23.04 89.05,22.09
|
||||||
|
2.39,11.90 2.37,12.22 2.81,12.38 3.60,11.70
|
||||||
|
18.61,3.48 18.62,3.30 18.49,2.92 18.21,2.41 18.07,2.01 18.06,1.53 17.90,1.12 17.89,0.23 17.72,-0.28 17.75,-0.55 17.28,-1.00 16.88,-1.23 16.54,-1.84 16.22,-2.18 16.22,-3.03 16.15,-3.46 15.99,-3.77 15.87,-3.93 15.60,-4.03 15.27,-4.31 14.71,-4.88 14.41,-4.83 14.37,-4.59 14.45,-4.45 14.36,-4.30 13.94,-4.48 13.72,-4.45 13.66,-4.72 13.41,-4.84 13.14,-4.60 13.07,-4.63
|
||||||
|
28.98,-30.00 29.10,-29.92 29.14,-29.70 29.29,-29.57 29.39,-29.27 29.30,-29.09 28.95,-28.88 28.63,-28.58 27.74,-28.94 27.29,-29.52 27.06,-29.63 27.39,-30.32 27.75,-30.60 28.06,-30.63 28.39,-30.15 28.65,-30.13 28.98,-30.00
|
||||||
|
29.36,-22.19 29.66,-22.15 30.19,-22.29 30.92,-22.29 31.29,-22.40
|
||||||
|
19.98,-24.78 20.43,-25.15 20.79,-25.92 20.82,-26.16 20.63,-26.44 20.69,-26.82 21.69,-26.84 21.79,-26.71 22.09,-26.58 22.22,-26.39 22.60,-26.13 22.88,-25.46 23.06,-25.31 23.39,-25.29 23.89,-25.60 24.19,-25.63 24.33,-25.74 24.75,-25.82 25.44,-25.71 25.58,-25.61 25.91,-24.75 26.40,-24.61 26.84,-24.24 26.99,-23.70 27.09,-23.58 27.76,-23.20 28.21,-22.69 28.38,-22.59 28.84,-22.48 29.13,-22.21 29.36,-22.19
|
||||||
|
34.96,-11.58 35.56,-11.60 35.79,-11.45 35.91,-11.45 36.31,-11.71 36.67,-11.68 36.98,-11.57 37.37,-11.71 37.72,-11.58 37.92,-11.29 38.18,-11.28 38.49,-11.41 38.79,-11.23 39.32,-11.12 39.99,-10.82 40.46,-10.46
|
||||||
|
-84.35,10.98 -84.17,10.78 -83.92,10.74 -83.71,10.79 -83.64,10.92
|
||||||
|
28.15,56.14 27.90,56.08 27.64,55.91 27.58,55.80 27.05,55.83 26.59,55.67
|
||||||
|
28.21,45.45 28.45,45.29 28.76,45.23 28.78,45.31 29.40,45.42 29.71,45.26
|
||||||
|
21.36,44.83 21.91,44.67 22.09,44.54 22.50,44.71 22.64,44.65 22.73,44.57 22.55,44.54 22.49,44.44 22.71,44.24
|
||||||
|
56.23,25.30 56.24,25.21 56.29,25.28 56.23,25.30
|
||||||
|
32.11,-26.84 32.89,-26.85
|
||||||
|
71.74,39.98 71.67,39.95 71.75,39.91 71.79,40.00 71.74,39.98
|
||||||
|
-122.69,48.99 -95.16,48.99 -95.16,49.37 -94.85,49.30 -94.80,49.00 -94.62,48.74 -94.06,48.66 -93.71,48.53 -93.38,48.62 -93.00,48.61 -92.50,48.44 -92.41,48.28 -92.17,48.34 -92.01,48.30 -91.52,48.06 -90.92,48.21 -90.74,48.10 -90.09,48.12 -89.90,48.00 -89.46,48.00 -88.38,48.30 -84.88,46.90 -84.78,46.64 -84.56,46.46 -84.15,46.54 -84.09,46.23 -83.98,46.08 -83.62,46.12 -83.47,45.99 -83.59,45.82 -82.55,45.35 -82.14,43.57 -82.41,43.07 -82.55,42.62 -83.07,42.30 -83.15,42.14 -83.14,41.98 -83.03,41.83 -82.69,41.68 -82.44,41.67 -81.28,42.21 -80.25,42.37 -79.04,42.80 -78.92,42.91 -79.03,43.02 -79.06,43.28 -79.17,43.47 -78.85,43.58 -76.82,43.63 -76.46,44.06 -76.15,44.30 -75.00,44.97 -71.52,45.01 -71.33,45.29 -71.13,45.26 -71.00,45.34 -70.87,45.27 -70.80,45.40 -70.69,45.43 -70.70,45.55 -70.42,45.74 -70.30,45.91 -70.25,46.25 -70.07,46.44 -70.01,46.71 -69.24,47.46 -69.05,47.43 -69.05,47.27 -68.94,47.21 -68.24,47.35 -67.81,47.08 -67.80,45.73 -67.43,45.60 -67.49,45.47 -67.43,45.38 -67.47,45.28 -67.37,45.17 -67.12,45.17
|
||||||
|
34.96,-11.58 34.89,-11.39 34.77,-11.34 34.61,-11.08 34.66,-10.71 34.58,-10.53 34.52,-10.03 34.32,-9.73 34.09,-9.54 34.00,-9.50 33.95,-9.66 33.89,-9.67
|
||||||
|
107.97,21.51 107.76,21.66 107.35,21.61 107.27,21.71 107.06,21.79 106.97,21.92 106.66,21.98 106.65,22.24 106.55,22.34 106.55,22.50 106.78,22.78 106.54,22.91 106.28,22.86 106.15,22.97 105.84,22.92 105.55,23.07 105.28,23.35 104.86,23.14 104.80,22.91 104.69,22.82 104.37,22.70 104.14,22.80 104.05,22.75 103.94,22.54 103.62,22.78 103.49,22.59 103.33,22.77 102.98,22.45 102.83,22.59 102.47,22.75 102.13,22.38
|
||||||
|
7.44,43.75 7.38,43.73
|
||||||
|
12.43,41.91 12.43,41.91
|
||||||
|
113.55,22.22 113.48,22.20
|
||||||
|
114.02,22.51 114.12,22.57 114.27,22.54
|
||||||
|
70.61,39.79 70.48,39.88 70.70,39.85 70.61,39.79
|
||||||
|
71.22,39.91 71.01,39.90 71.04,39.99 70.97,40.04 70.98,40.13 71.23,40.05 71.18,39.98 71.22,39.91
|
||||||
|
145.94,43.10 145.78,43.48 145.69,43.55 145.54,43.51 145.24,43.78 145.27,43.91 145.62,44.29 145.66,44.45 145.58,44.79
|
||||||
|
-1.28,32.09 -1.23,32.11 -1.24,32.34 -1.07,32.47 -1.45,32.78 -1.68,33.32 -1.63,33.57 -1.71,33.78 -1.71,34.18 -1.79,34.37 -1.73,34.47 -1.85,34.61 -1.80,34.75 -2.13,34.97 -2.22,35.10
|
||||||
|
29.00,9.61 29.47,9.77 29.60,9.92 29.61,10.07 30.00,10.28 30.76,9.73 31.22,9.80 31.79,10.38 31.93,10.66 32.42,11.09 32.34,11.31 32.34,11.72 32.07,12.01 32.74,12.01 32.72,12.22 33.20,12.22 33.07,11.59 33.17,10.85 33.13,10.76 33.38,10.65 33.91,10.18 33.96,9.86 33.87,9.51 34.08,9.46
|
||||||
|
24.15,8.67 24.21,8.77 24.53,8.89 24.67,9.39 24.78,9.53 24.79,9.81 24.96,9.99 25.02,10.24 25.10,10.31 25.86,10.41 25.92,10.17 26.55,9.53 26.66,9.48 27.07,9.61 27.88,9.60
|
||||||
|
27.88,9.60 28.05,9.33 28.84,9.33 28.84,9.46 29.00,9.61
|
||||||
|
-56.48,1.94 -56.70,2.04 -57.20,2.85 -57.30,3.38 -57.60,3.37
|
||||||
|
-54.62,2.33 -54.40,2.46 -54.20,2.82 -54.20,3.14 -54.08,3.33
|
||||||
|
33.89,-9.67 33.70,-9.60 33.42,-9.61 33.33,-9.52 32.92,-9.41
|
||||||
|
33.59,46.10 33.66,46.22 34.22,46.10 34.45,45.97 34.69,45.98 34.79,45.89 34.80,45.79 35.00,45.73
|
||||||
|
77.05,35.11 76.77,35.66
|
||||||
|
77.05,35.11 77.80,35.50
|
||||||
|
79.12,33.23 79.38,33.15 79.33,32.98 79.50,32.73 79.38,32.57 79.22,32.50
|
||||||
|
79.22,32.50 78.92,32.36 78.70,32.60 78.41,32.56 78.49,32.23
|
||||||
|
78.49,32.23 78.73,31.98 78.72,31.88
|
||||||
|
78.72,31.88 78.69,31.74 78.80,31.62 78.73,31.47 78.74,31.32 78.84,31.30
|
||||||
|
78.84,31.30 78.97,31.33 79.01,31.41 79.11,31.40 79.37,31.08
|
||||||
|
79.37,31.08 79.57,30.95 79.79,30.97 79.91,30.90
|
||||||
|
79.91,30.90 80.19,30.76 80.19,30.57
|
||||||
|
80.19,30.57 80.68,30.41 80.99,30.24 81.01,30.16
|
||||||
|
90.33,28.12 90.35,28.24 90.22,28.28 89.98,28.31 89.80,28.24
|
||||||
|
89.80,28.24 89.48,28.06 89.12,27.62
|
||||||
|
89.12,27.62 89.02,27.51
|
||||||
|
89.02,27.51 88.89,27.32
|
||||||
|
75.30,32.32 75.32,32.22 75.25,32.14 74.74,31.95 74.56,31.82 74.51,31.71 74.59,31.47 74.52,31.19 74.63,31.03 74.34,30.89 73.90,30.44 73.93,30.22 73.81,30.09 73.38,29.93 73.23,29.55 72.90,29.03 72.34,28.75 72.13,28.35 71.95,28.18 71.87,27.96 70.80,27.71 70.69,27.77 70.63,27.94 70.40,28.03 70.14,27.85 69.90,27.47 69.54,27.12 69.47,26.80 69.51,26.74 70.15,26.51 70.10,25.91 70.26,25.71 70.65,25.67 70.65,25.42 71.05,24.69 70.97,24.57 71.04,24.40 70.72,24.24 70.58,24.28 70.55,24.42 70.49,24.41 70.10,24.29 70.02,24.19 69.81,24.17 69.56,24.27 68.83,24.26 68.78,24.31 68.73,24.27 68.72,23.96 68.38,23.95 68.17,23.86
|
||||||
|
36.69,45.62 36.69,45.43 36.59,45.30 36.64,45.26 36.51,45.20 36.61,44.95
|
||||||
|
-55.81,-31.03 -56.00,-31.08 -56.00,-30.85
|
||||||
|
-56.00,-30.85 -56.18,-30.63 -56.83,-30.11 -57.12,-30.14 -57.21,-30.28 -57.55,-30.26 -57.61,-30.19
|
||||||
|
-73.48,-49.77 -73.58,-49.58 -73.46,-49.31 -73.14,-49.30
|
||||||
|
-73.14,-49.30 -73.15,-49.19 -73.03,-49.01 -72.65,-48.84 -72.58,-48.48 -72.35,-48.37 -72.29,-48.23 -72.33,-48.11 -72.51,-47.97 -72.52,-47.88 -72.35,-47.49 -72.04,-47.24 -71.90,-47.20 -71.96,-47.02 -71.94,-46.83 -71.70,-46.65 -71.76,-46.32 -71.88,-46.16 -71.63,-45.95 -71.75,-45.84 -71.75,-45.58 -71.51,-45.51 -71.35,-45.33 -71.35,-45.23 -71.60,-44.98 -72.04,-44.90 -72.06,-44.77 -71.26,-44.76 -71.16,-44.56 -71.21,-44.44 -71.82,-44.38 -71.81,-44.11 -71.68,-43.93 -71.79,-43.75 -71.74,-43.70 -71.75,-43.59 -71.90,-43.44 -71.90,-43.35 -71.75,-43.24 -71.78,-43.17 -72.10,-43.07 -72.14,-42.58 -72.05,-42.47 -72.11,-42.25 -72.03,-42.15 -71.86,-42.15 -71.75,-42.05 -71.91,-41.65 -71.87,-40.89 -71.94,-40.79 -71.80,-40.44 -71.70,-40.34 -71.82,-40.18 -71.66,-40.02 -71.64,-39.89 -71.72,-39.64 -71.54,-39.60 -71.42,-39.29 -71.40,-38.94 -70.95,-38.74 -70.85,-38.54 -70.97,-38.45 -71.03,-38.04 -71.17,-37.76 -71.13,-37.45 -71.20,-37.30 -71.12,-37.11 -71.19,-36.84 -71.06,-36.52 -70.75,-36.39 -70.72,-36.28 -70.40,-36.06 -70.42,-35.52 -70.56,-35.25 -70.39,-35.15 -70.29,-34.73 -70.05,-34.30 -69.85,-34.22 -69.89,-33.73 -69.80,-33.40 -69.82,-33.28 -70.02,-33.27 -70.08,-33.20 -70.09,-33.03 -70.02,-32.88 -70.12,-32.81 -70.17,-32.47 -70.32,-32.27 -70.36,-32.08 -70.25,-31.96 -70.45,-31.84 -70.59,-31.57 -70.52,-31.15 -70.39,-31.12 -70.31,-31.02 -70.35,-30.90 -70.15,-30.36 -69.96,-30.36 -69.84,-30.18 -69.96,-30.08 -69.93,-29.77 -70.03,-29.32 -69.83,-29.10 -69.66,-28.41 -69.17,-27.92 -68.85,-27.15 -68.71,-27.10 -68.59,-27.14 -68.32,-26.97 -68.32,-26.88 -68.59,-26.47 -68.58,-26.35 -68.41,-26.15 -68.59,-25.42 -68.50,-25.16 -68.38,-25.09 -68.56,-24.84 -68.56,-24.75 -68.25,-24.39 -67.36,-24.03 -67.01,-23.00 -67.19,-22.82
|
||||||
|
75.32,34.58 74.30,34.77 73.96,34.65 73.81,34.42 73.81,34.33 73.97,34.24 73.92,34.04 74.25,33.99 74.22,33.89 74.00,33.79 73.98,33.72 74.15,33.51 73.99,33.22 74.30,32.99 74.31,32.81
|
||||||
|
23.61,51.52 23.54,51.71 23.63,51.81 23.65,52.04 23.18,52.29 23.41,52.52 23.84,52.66 23.92,52.77 23.86,53.11 23.48,53.94
|
||||||
|
34.08,9.46 34.16,9.85 34.31,10.19 34.28,10.53 34.34,10.66 34.57,10.88 34.77,10.75 34.93,10.86 34.96,11.28 35.11,11.82 35.67,12.62 36.13,12.76 36.16,13.09 36.39,13.63 36.52,14.26
|
||||||
|
2.39,11.90 2.29,11.69 1.98,11.42 1.43,11.45 1.32,11.30 1.15,11.25 1.08,11.08 0.99,11.08 0.90,10.99
|
||||||
|
31.95,-25.96 31.87,-25.98 31.42,-25.75 31.21,-25.84 30.80,-26.41 30.79,-26.76 30.88,-26.79 31.06,-27.11 31.47,-27.30 31.96,-27.31 31.99,-26.82 32.11,-26.84
|
||||||
|
32.11,-26.84 32.06,-26.02 31.95,-25.96
|
||||||
|
76.77,35.66 76.56,35.77 76.55,35.89 76.15,35.83 76.07,35.98 75.91,36.05 75.97,36.38
|
||||||
|
27.33,5.19 27.79,4.64 28.19,4.35 28.43,4.32 28.73,4.50 29.22,4.39 29.47,4.61 29.68,4.59 30.19,3.98 30.51,3.84 30.59,3.62 30.76,3.62 30.84,3.49
|
||||||
|
22.86,10.92 23.31,10.39 23.65,9.82 23.62,9.34 23.47,9.11 23.55,8.94 23.54,8.82 23.68,8.73 24.15,8.67
|
||||||
|
46.11,38.88 46.49,38.91
|
||||||
|
44.78,39.68 45.14,39.25 45.48,39.01 46.11,38.88
|
||||||
|
46.49,38.91 47.48,39.50 47.77,39.65 48.00,39.68 48.32,39.40 48.10,39.24 48.29,39.02 48.05,38.93 48.00,38.85 48.59,38.41 48.87,38.44
|
||||||
|
81.01,30.16 80.85,30.14 80.40,29.73 80.25,29.42 80.23,29.19 80.08,28.99 80.07,28.83 80.42,28.61 80.59,28.65 81.85,27.87 81.99,27.91 82.45,27.67 82.68,27.67 82.73,27.52 83.29,27.37 83.45,27.47 83.83,27.38 84.09,27.49 84.61,27.30 84.69,27.04 85.13,26.86 85.24,26.75 85.57,26.84 85.70,26.78 85.79,26.60 86.01,26.65 86.70,26.44 87.02,26.56 87.09,26.43 87.29,26.36 87.41,26.42 87.85,26.44 88.00,26.38 88.16,26.72 87.98,27.13 88.15,27.75 88.11,27.87
|
||||||
|
75.97,36.38 75.84,36.65 75.67,36.74 75.42,36.74 75.35,36.91 74.54,37.02
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Filters": a funnel, wide at the top and narrowing to a short spout.
|
||||||
|
No stock icon theme ships one under a standard name. -->
|
||||||
|
<path fill="#222222" d="M 1.5,2 H 14.5 L 9.5,8.5 V 13.5 L 6.5,15 V 8.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
||||||
|
<path fill="#6d4aff" d="M4 12a4 4 0 0 1 4-4h11.2a4 4 0 0 1 2.9 1.2L25 12h15a4 4 0 0 1 4 4v22a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z"/>
|
||||||
|
<path fill="#8a6eff" d="M4 19a3 3 0 0 1 3-3h34a3 3 0 0 1 3 3v19a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z"/>
|
||||||
|
<path fill="#ffffff" fill-opacity="0.18" d="M7 16h34a3 3 0 0 1 3 3v1a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v-1a3 3 0 0 1 3-3z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 442 B |
@@ -7,13 +7,21 @@
|
|||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-filter-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
||||||
|
<!-- The Proton theme's folder: a full-colour icon from the icon theme
|
||||||
|
can't be recoloured by CSS. -->
|
||||||
|
<file preprocess="xml-stripblanks">scalable/places/pdfs-proton-folder.svg</file>
|
||||||
|
<!-- The app icon, from the one file packaging installs, so the in-app brand
|
||||||
|
matches the launcher and resolves even when nothing is installed. -->
|
||||||
|
<file alias="scalable/apps/io.narl.proton-drive-linux.svg" preprocess="xml-stripblanks">../../../packaging/io.narl.proton-drive-linux.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
||||||
libadwaita palette so the app follows the system accent, light/dark and
|
libadwaita palette so the app follows the system accent, light/dark and
|
||||||
high contrast; the Proton purple accent is a separate, opt-in provider. -->
|
high contrast. The Proton theme is a separate, opt-in provider. -->
|
||||||
<gresource prefix="/de/nils/protondrivelinux">
|
<gresource prefix="/de/nils/protondrivelinux">
|
||||||
<file>style.css</file>
|
<file>style.css</file>
|
||||||
|
<file>proton-theme.css</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* Proton theme — the Proton web apps' Snow (light) and Carbon (dark) palettes.
|
||||||
|
*
|
||||||
|
* Loaded by main.rs after libadwaita's own stylesheet, which it re-applies on
|
||||||
|
* top of the user's GTK theme so that theme's hard-coded colours can't show
|
||||||
|
* through. Only libadwaita's colour variables are set here; every widget
|
||||||
|
* derives its look from them. Values from ProtonMail/WebClients,
|
||||||
|
* packages/colors/themes/src/{snow,carbon}/standard-base.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent-bg-color: #6d4aff;
|
||||||
|
--accent-fg-color: #ffffff;
|
||||||
|
--destructive-bg-color: #dc3251;
|
||||||
|
--destructive-fg-color: #ffffff;
|
||||||
|
--error-bg-color: #dc3251;
|
||||||
|
--error-fg-color: #ffffff;
|
||||||
|
--success-bg-color: #1ea885;
|
||||||
|
--success-fg-color: #ffffff;
|
||||||
|
--warning-bg-color: #ff9900;
|
||||||
|
--warning-fg-color: rgb(0 0 0 / 80%);
|
||||||
|
|
||||||
|
--window-bg-color: #ffffff;
|
||||||
|
--window-fg-color: #0c0c14;
|
||||||
|
--view-bg-color: #ffffff;
|
||||||
|
--view-fg-color: #0c0c14;
|
||||||
|
--headerbar-bg-color: #ffffff;
|
||||||
|
--headerbar-fg-color: #0c0c14;
|
||||||
|
--headerbar-backdrop-color: #ffffff;
|
||||||
|
--sidebar-bg-color: #f5f4f2;
|
||||||
|
--sidebar-fg-color: #0c0c14;
|
||||||
|
--sidebar-backdrop-color: #f8f8f6;
|
||||||
|
--secondary-sidebar-bg-color: #f8f8f6;
|
||||||
|
--secondary-sidebar-fg-color: #0c0c14;
|
||||||
|
--secondary-sidebar-backdrop-color: #f8f8f6;
|
||||||
|
--card-bg-color: #f5f4f2;
|
||||||
|
--card-fg-color: #0c0c14;
|
||||||
|
--dialog-bg-color: #ffffff;
|
||||||
|
--dialog-fg-color: #0c0c14;
|
||||||
|
--popover-bg-color: #ffffff;
|
||||||
|
--popover-fg-color: #0c0c14;
|
||||||
|
--thumbnail-bg-color: #f5f4f2;
|
||||||
|
--thumbnail-fg-color: #0c0c14;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--destructive-bg-color: #f5385a;
|
||||||
|
--error-bg-color: #f5385a;
|
||||||
|
|
||||||
|
--window-bg-color: #1e1c26;
|
||||||
|
--window-fg-color: #ffffff;
|
||||||
|
--view-bg-color: #1e1c26;
|
||||||
|
--view-fg-color: #ffffff;
|
||||||
|
--headerbar-bg-color: #1e1c26;
|
||||||
|
--headerbar-fg-color: #ffffff;
|
||||||
|
--headerbar-backdrop-color: #1e1c26;
|
||||||
|
--sidebar-bg-color: #16141c;
|
||||||
|
--sidebar-fg-color: #ffffff;
|
||||||
|
--sidebar-backdrop-color: #16141c;
|
||||||
|
--secondary-sidebar-bg-color: #1a1821;
|
||||||
|
--secondary-sidebar-fg-color: #ffffff;
|
||||||
|
--secondary-sidebar-backdrop-color: #1a1821;
|
||||||
|
--card-bg-color: #292733;
|
||||||
|
--card-fg-color: #ffffff;
|
||||||
|
--dialog-bg-color: #292733;
|
||||||
|
--dialog-fg-color: #ffffff;
|
||||||
|
--popover-bg-color: #292733;
|
||||||
|
--popover-fg-color: #ffffff;
|
||||||
|
--thumbnail-bg-color: #292733;
|
||||||
|
--thumbnail-fg-color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@
|
|||||||
* var(--success-color), ...) so the app follows the system accent, light/dark
|
* var(--success-color), ...) so the app follows the system accent, light/dark
|
||||||
* and high contrast. The one exception is the photo viewer, which always sits
|
* and high contrast. The one exception is the photo viewer, which always sits
|
||||||
* on a near-black backdrop and so uses fixed light-on-dark colours. The Proton
|
* on a near-black backdrop and so uses fixed light-on-dark colours. The Proton
|
||||||
* purple accent is opt-in and lives in its own provider (see main.rs).
|
* theme is opt-in and lives in its own provider (see main.rs and
|
||||||
|
* proton-theme.css).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Brand */
|
/* Brand */
|
||||||
.brand-title { font-size: 1.6rem; font-weight: 800; }
|
.brand-title { font-size: 1.6rem; font-weight: 800; }
|
||||||
.brand-icon { color: #6d4aff; }
|
|
||||||
|
|
||||||
/* Sidebar footer */
|
/* Sidebar footer */
|
||||||
.sidebar-footer { padding: 6px 12px 12px 12px; }
|
.sidebar-footer { padding: 6px 12px 12px 12px; }
|
||||||
@@ -59,8 +59,8 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
|||||||
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
||||||
|
|
||||||
/* Photos grid */
|
/* Photos grid */
|
||||||
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }
|
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; }
|
||||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
|
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
|
||||||
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
|
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
|
||||||
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
||||||
.photo-thumb { transition: transform 220ms ease; }
|
.photo-thumb { transition: transform 220ms ease; }
|
||||||
@@ -72,8 +72,12 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
|||||||
.photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }
|
.photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }
|
||||||
.photo-tile:hover .photo-caption { opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }
|
.photo-tile:hover .photo-caption { opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }
|
||||||
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
|
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
|
||||||
|
.photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; }
|
||||||
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
|
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
|
||||||
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
|
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
|
||||||
|
.memory-card { padding: 0; border-radius: 12px; }
|
||||||
|
.memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); }
|
||||||
|
.memory-label { font-weight: 700; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); }
|
||||||
.album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; }
|
.album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; }
|
||||||
.album-card:hover { background: alpha(currentColor, 0.07); }
|
.album-card:hover { background: alpha(currentColor, 0.07); }
|
||||||
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
||||||
@@ -83,3 +87,14 @@ progressbar.browser-status-meter trough, progressbar.browser-status-meter progre
|
|||||||
|
|
||||||
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
||||||
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
||||||
|
|
||||||
|
/* Photos: how many timeline filters are on, as a small badge on the Filters button. */
|
||||||
|
.filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); }
|
||||||
|
/* The Places map: sea and land come in the map's own colours; markers (drawn
|
||||||
|
* in the area's own colour) in the accent. */
|
||||||
|
.places-map-frame { border-radius: 12px; }
|
||||||
|
.places-map { color: var(--accent-bg-color); }
|
||||||
|
/* Credit on the Places street map, in the corner as on every web map. */
|
||||||
|
.places-spot-photo { padding: 0; }
|
||||||
|
.places-spot-photo picture { border-radius: 6px; overflow: hidden; }
|
||||||
|
.places-map-credit { padding: 2px 8px; border-top-left-radius: 8px; color: var(--window-fg-color); background: color-mix(in srgb, var(--window-bg-color) 80%, transparent); }
|
||||||
+176
-128
@@ -9,10 +9,13 @@ use pages::activity::*;
|
|||||||
use pages::albums::*;
|
use pages::albums::*;
|
||||||
use pages::browser::*;
|
use pages::browser::*;
|
||||||
use pages::devices::*;
|
use pages::devices::*;
|
||||||
|
use pages::duplicates::*;
|
||||||
use pages::locations::*;
|
use pages::locations::*;
|
||||||
use pages::login::*;
|
use pages::login::*;
|
||||||
use pages::photo_viewer::*;
|
use pages::photo_viewer::*;
|
||||||
use pages::photos::*;
|
use pages::photos::*;
|
||||||
|
use pages::places::*;
|
||||||
|
use pages::places_map::*;
|
||||||
use pages::shared::*;
|
use pages::shared::*;
|
||||||
use pages::shared_by_me::*;
|
use pages::shared_by_me::*;
|
||||||
use pages::status::*;
|
use pages::status::*;
|
||||||
@@ -25,7 +28,7 @@ use widgets::share_dialog::*;
|
|||||||
use widgets::thumbnails::*;
|
use widgets::thumbnails::*;
|
||||||
use widgets::versions_dialog::*;
|
use widgets::versions_dialog::*;
|
||||||
|
|
||||||
pub(crate) use i18n::{gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
|
pub(crate) use i18n::{country_name, gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
|
||||||
|
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
|
|
||||||
@@ -53,10 +56,11 @@ use pdfs_core::config::{AppDirs, FileSort, FilesView};
|
|||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem,
|
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
||||||
PhotoKind, PhotoMonth, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder,
|
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||||
RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo,
|
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||||
SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem, send,
|
SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection, TransferItem,
|
||||||
|
send,
|
||||||
};
|
};
|
||||||
|
|
||||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
@@ -65,10 +69,6 @@ use pdfs_core::service;
|
|||||||
|
|
||||||
const APP_ID: &str = "io.narl.proton-drive-linux";
|
const APP_ID: &str = "io.narl.proton-drive-linux";
|
||||||
|
|
||||||
/// Proton brand purple, applied as the libadwaita accent when the user opts in
|
|
||||||
/// (Preferences → Appearance), so switches, buttons and links all pick it up.
|
|
||||||
const PROTON_PURPLE: &str = "#6d4aff";
|
|
||||||
|
|
||||||
/// How often the window re-reads mount status, cache usage and the pin list.
|
/// How often the window re-reads mount status, cache usage and the pin list.
|
||||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
|
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ fn main() -> glib::ExitCode {
|
|||||||
app.connect_startup(|_| {
|
app.connect_startup(|_| {
|
||||||
load_proton_theme();
|
load_proton_theme();
|
||||||
if let Ok(dirs) = AppDirs::new() {
|
if let Ok(dirs) = AppDirs::new() {
|
||||||
set_proton_accent(dirs.load_config().proton_accent.unwrap_or(false));
|
set_proton_theme(dirs.load_config().proton_theme.unwrap_or(false));
|
||||||
}
|
}
|
||||||
// Refresh the file manager's right-click pin/unpin scripts, so they always
|
// Refresh the file manager's right-click pin/unpin scripts, so they always
|
||||||
// match the installed `pdfs`.
|
// match the installed `pdfs`.
|
||||||
@@ -285,8 +285,8 @@ pub(crate) fn spawn_tray() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Register the bundled GResources (custom icons, the stylesheet) and load the
|
/// Register the bundled GResources (custom icons, the stylesheet) and load the
|
||||||
/// app stylesheet. The Proton accent is a separate provider, applied by
|
/// app stylesheet. The Proton theme is a separate provider, applied by
|
||||||
/// [`set_proton_accent`] from the saved preference.
|
/// [`set_proton_theme`] from the saved preference.
|
||||||
fn load_proton_theme() {
|
fn load_proton_theme() {
|
||||||
let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource"));
|
let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource"));
|
||||||
let resource_data = glib::Bytes::from_static(bytes);
|
let resource_data = glib::Bytes::from_static(bytes);
|
||||||
@@ -301,38 +301,128 @@ fn load_proton_theme() {
|
|||||||
};
|
};
|
||||||
let provider = gtk4::CssProvider::new();
|
let provider = gtk4::CssProvider::new();
|
||||||
provider.load_from_resource("/de/nils/protondrivelinux/style.css");
|
provider.load_from_resource("/de/nils/protondrivelinux/style.css");
|
||||||
|
// Above the user's own gtk.css, which often imports a whole theme: its
|
||||||
|
// `button { padding: 6px 10px; min-width: 16px }` would otherwise pad every
|
||||||
|
// photo tile and push the grid past the window. Every rule in the sheet is
|
||||||
|
// scoped to the app's own classes, so nothing else the user styled changes.
|
||||||
|
// One above the Proton theme too, so the app's rules refine it.
|
||||||
gtk4::style_context_add_provider_for_display(
|
gtk4::style_context_add_provider_for_display(
|
||||||
&display,
|
&display,
|
||||||
&provider,
|
&provider,
|
||||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
gtk4::STYLE_PROVIDER_PRIORITY_USER + 2,
|
||||||
);
|
);
|
||||||
gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons");
|
gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons");
|
||||||
}
|
}
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
/// The Proton purple accent provider while it is installed. Kept so turning
|
/// The Proton theme provider while it is installed. Kept so turning the
|
||||||
/// the preference off can remove exactly what turning it on added.
|
/// preference off can remove exactly what turning it on added.
|
||||||
static PROTON_ACCENT: RefCell<Option<gtk4::CssProvider>> = const { RefCell::new(None) };
|
static PROTON_THEME: RefCell<Option<gtk4::CssProvider>> = const { RefCell::new(None) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint the app in Proton purple (`on`) or follow the system accent colour.
|
/// Whether the Proton theme is on.
|
||||||
/// Every accent-coloured widget reads `--accent-bg-color`, so overriding that
|
fn proton_theme_active() -> bool {
|
||||||
/// one variable recolours the whole app.
|
PROTON_THEME.with(|slot| slot.borrow().is_some())
|
||||||
fn set_proton_accent(on: bool) {
|
}
|
||||||
|
|
||||||
|
/// libadwaita's own stylesheet, bundled in the library.
|
||||||
|
const ADWAITA_STYLESHEET: &str = "/org/gnome/Adwaita/styles/gtk.css";
|
||||||
|
|
||||||
|
/// The Proton palette laid over it, from our own bundle.
|
||||||
|
const PROTON_PALETTE: &str = "/de/nils/protondrivelinux/proton-theme.css";
|
||||||
|
|
||||||
|
/// Make the theme provider's `@media (prefers-color-scheme)` and
|
||||||
|
/// `(prefers-contrast)` rules match what libadwaita shows, now and whenever it
|
||||||
|
/// changes. A provider evaluates those itself, and a new one assumes light.
|
||||||
|
///
|
||||||
|
/// The properties are set by name because they only exist since GTK 4.20, and
|
||||||
|
/// the app still runs on older GTK, whose libadwaita has no such rules anyway.
|
||||||
|
fn follow_style_manager(provider: >k4::CssProvider) {
|
||||||
|
fn set_enum(provider: >k4::CssProvider, property: &str, nick: &str) {
|
||||||
|
let Some(pspec) = provider.find_property(property) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(value) = glib::EnumClass::with_type(pspec.value_type())
|
||||||
|
.and_then(|class| class.to_value_by_nick(nick))
|
||||||
|
{
|
||||||
|
provider.set_property_from_value(property, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn apply(provider: >k4::CssProvider, manager: &adw::StyleManager) {
|
||||||
|
let scheme = if manager.is_dark() { "dark" } else { "light" };
|
||||||
|
set_enum(provider, "prefers-color-scheme", scheme);
|
||||||
|
let contrast = if manager.is_high_contrast() {
|
||||||
|
"more"
|
||||||
|
} else {
|
||||||
|
"no-preference"
|
||||||
|
};
|
||||||
|
set_enum(provider, "prefers-contrast", contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
let manager = adw::StyleManager::default();
|
||||||
|
apply(provider, &manager);
|
||||||
|
// Weak, so a provider the preference removed isn't kept alive; its
|
||||||
|
// handlers then do nothing.
|
||||||
|
let weak = provider.downgrade();
|
||||||
|
let weak_dark = weak.clone();
|
||||||
|
manager.connect_dark_notify(move |manager| {
|
||||||
|
if let Some(provider) = weak_dark.upgrade() {
|
||||||
|
apply(&provider, manager);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
manager.connect_high_contrast_notify(move |manager| {
|
||||||
|
if let Some(provider) = weak.upgrade() {
|
||||||
|
apply(&provider, manager);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint the app in the Proton web apps' colours (`on`) or follow the system
|
||||||
|
/// theme and accent colour.
|
||||||
|
///
|
||||||
|
/// Setting libadwaita's colour variables alone isn't enough: a GTK theme the
|
||||||
|
/// user's `gtk.css` imports (Catppuccin, Fluent, ...) sits above the app and
|
||||||
|
/// hard-codes its own accent into hundreds of rules. So the theme provider,
|
||||||
|
/// one above the user's CSS, first resets every property, then re-applies
|
||||||
|
/// libadwaita's whole stylesheet and finally the Proton palette, which leaves
|
||||||
|
/// nothing of the user's theme inside the app.
|
||||||
|
fn set_proton_theme(on: bool) {
|
||||||
let Some(display) = gtk4::gdk::Display::default() else {
|
let Some(display) = gtk4::gdk::Display::default() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
PROTON_ACCENT.with(|slot| {
|
PROTON_THEME.with(|slot| {
|
||||||
let mut slot = slot.borrow_mut();
|
let mut slot = slot.borrow_mut();
|
||||||
match (on, slot.as_ref()) {
|
match (on, slot.as_ref()) {
|
||||||
(true, None) => {
|
(true, None) => {
|
||||||
|
let stylesheet = |path| {
|
||||||
|
gio::resources_lookup_data(path, gio::ResourceLookupFlags::NONE)
|
||||||
|
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
|
||||||
|
};
|
||||||
|
let palette = stylesheet(PROTON_PALETTE).unwrap_or_default();
|
||||||
|
let css = match stylesheet(ADWAITA_STYLESHEET) {
|
||||||
|
// Its images are named relative to the sheet, which a
|
||||||
|
// string loaded here doesn't have.
|
||||||
|
Ok(adwaita) => format!(
|
||||||
|
"* {{ all: unset; }}\n{}\n{palette}",
|
||||||
|
adwaita.replace(
|
||||||
|
"url(\"assets/",
|
||||||
|
"url(\"resource:///org/gnome/Adwaita/styles/assets/"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Still recolours everything that follows libadwaita's
|
||||||
|
// variables; only a custom theme's own rules show through.
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("libadwaita stylesheet not found, only recolouring: {e}");
|
||||||
|
palette
|
||||||
|
}
|
||||||
|
};
|
||||||
let provider = gtk4::CssProvider::new();
|
let provider = gtk4::CssProvider::new();
|
||||||
provider
|
follow_style_manager(&provider);
|
||||||
.load_from_string(&format!(":root {{ --accent-bg-color: {PROTON_PURPLE}; }}"));
|
provider.load_from_string(&css);
|
||||||
gtk4::style_context_add_provider_for_display(
|
gtk4::style_context_add_provider_for_display(
|
||||||
&display,
|
&display,
|
||||||
&provider,
|
&provider,
|
||||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
|
gtk4::STYLE_PROVIDER_PRIORITY_USER + 1,
|
||||||
);
|
);
|
||||||
*slot = Some(provider);
|
*slot = Some(provider);
|
||||||
}
|
}
|
||||||
@@ -446,7 +536,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
upload_limit_row: main_widgets.upload_limit_row.clone(),
|
upload_limit_row: main_widgets.upload_limit_row.clone(),
|
||||||
download_limit_row: main_widgets.download_limit_row.clone(),
|
download_limit_row: main_widgets.download_limit_row.clone(),
|
||||||
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
||||||
accent_row: main_widgets.accent_row.clone(),
|
theme_row: main_widgets.theme_row.clone(),
|
||||||
|
online_map_row: main_widgets.online_map_row.clone(),
|
||||||
tray_row: main_widgets.tray_row.clone(),
|
tray_row: main_widgets.tray_row.clone(),
|
||||||
language_row: main_widgets.language_row.clone(),
|
language_row: main_widgets.language_row.clone(),
|
||||||
settings_suppress: Cell::new(false),
|
settings_suppress: Cell::new(false),
|
||||||
@@ -522,10 +613,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
model: gallery_widgets.model.clone(),
|
model: gallery_widgets.model.clone(),
|
||||||
groups: gallery_widgets.groups.clone(),
|
groups: gallery_widgets.groups.clone(),
|
||||||
row_height: Cell::new(ROW_DEFAULT),
|
row_height: Cell::new(ROW_DEFAULT),
|
||||||
learned_ratios: RefCell::new(HashMap::new()),
|
|
||||||
scrolling_down: Cell::new(true),
|
scrolling_down: Cell::new(true),
|
||||||
scroll_offset: Cell::new(0.0),
|
scroll_offset: Cell::new(0.0),
|
||||||
assumed_ratios: RefCell::new(HashSet::new()),
|
|
||||||
content: gallery_widgets.content.clone(),
|
content: gallery_widgets.content.clone(),
|
||||||
status: gallery_widgets.status.clone(),
|
status: gallery_widgets.status.clone(),
|
||||||
retry: gallery_widgets.retry.clone(),
|
retry: gallery_widgets.retry.clone(),
|
||||||
@@ -548,6 +637,19 @@ fn build_window(app: &adw::Application) {
|
|||||||
albums_btn: gallery_widgets.albums_btn.clone(),
|
albums_btn: gallery_widgets.albums_btn.clone(),
|
||||||
view_switch: gallery_widgets.view_switch.clone(),
|
view_switch: gallery_widgets.view_switch.clone(),
|
||||||
albums_loading: Cell::new(false),
|
albums_loading: Cell::new(false),
|
||||||
|
places: gallery_widgets.places.clone(),
|
||||||
|
places_stack: gallery_widgets.places_stack.clone(),
|
||||||
|
places_status: gallery_widgets.places_status.clone(),
|
||||||
|
places_btn: gallery_widgets.places_btn.clone(),
|
||||||
|
places_loading: Cell::new(false),
|
||||||
|
places_mapping: gallery_widgets.places_mapping.clone(),
|
||||||
|
places_mapping_label: gallery_widgets.places_mapping_label.clone(),
|
||||||
|
places_mapping_bar: gallery_widgets.places_mapping_bar.clone(),
|
||||||
|
places_poll: RefCell::new(None),
|
||||||
|
places_map: gallery_widgets.places_map.clone(),
|
||||||
|
places_map_btn: gallery_widgets.places_map_btn.clone(),
|
||||||
|
place_count: Cell::new(0),
|
||||||
|
place: RefCell::new(None),
|
||||||
album: RefCell::new(None),
|
album: RefCell::new(None),
|
||||||
timeline_stale: Cell::new(false),
|
timeline_stale: Cell::new(false),
|
||||||
album_list: RefCell::new(Vec::new()),
|
album_list: RefCell::new(Vec::new()),
|
||||||
@@ -557,11 +659,20 @@ fn build_window(app: &adw::Application) {
|
|||||||
tabs: gallery_widgets.tabs.clone(),
|
tabs: gallery_widgets.tabs.clone(),
|
||||||
counts: Cell::new(None),
|
counts: Cell::new(None),
|
||||||
dates: gallery_widgets.dates.clone(),
|
dates: gallery_widgets.dates.clone(),
|
||||||
|
filter_btn: gallery_widgets.filter_btn.clone(),
|
||||||
|
filter_count: gallery_widgets.filter_count.clone(),
|
||||||
|
clear_filters: gallery_widgets.clear_filters.clone(),
|
||||||
date_ranges: RefCell::new(vec![None]),
|
date_ranges: RefCell::new(vec![None]),
|
||||||
range: Cell::new(None),
|
range: Cell::new(None),
|
||||||
favorites_btn: gallery_widgets.favorites_btn.clone(),
|
favorites_row: gallery_widgets.favorites_row.clone(),
|
||||||
favorites: Cell::new(false),
|
favorites: Cell::new(false),
|
||||||
|
not_in_album_row: gallery_widgets.not_in_album_row.clone(),
|
||||||
|
not_in_album: Cell::new(false),
|
||||||
|
memories: gallery_widgets.memories.clone(),
|
||||||
|
memory_cards: gallery_widgets.memory_cards.clone(),
|
||||||
|
has_memories: Cell::new(false),
|
||||||
date_suppress: Cell::new(false),
|
date_suppress: Cell::new(false),
|
||||||
|
reload_pending: Cell::new(false),
|
||||||
loading: Cell::new(false),
|
loading: Cell::new(false),
|
||||||
has_more: Cell::new(false),
|
has_more: Cell::new(false),
|
||||||
page_waiters: RefCell::new(Vec::new()),
|
page_waiters: RefCell::new(Vec::new()),
|
||||||
@@ -580,6 +691,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
list: gallery_widgets.list.clone(),
|
list: gallery_widgets.list.clone(),
|
||||||
selecting: Cell::new(false),
|
selecting: Cell::new(false),
|
||||||
selected: RefCell::new(HashSet::new()),
|
selected: RefCell::new(HashSet::new()),
|
||||||
|
select_anchor: RefCell::new(None),
|
||||||
select_btn: gallery_widgets.select_btn.clone(),
|
select_btn: gallery_widgets.select_btn.clone(),
|
||||||
select_bar: gallery_widgets.select_bar.clone(),
|
select_bar: gallery_widgets.select_bar.clone(),
|
||||||
select_label: gallery_widgets.select_label.clone(),
|
select_label: gallery_widgets.select_label.clone(),
|
||||||
@@ -718,7 +830,12 @@ fn build_window(app: &adw::Application) {
|
|||||||
&gallery_widgets.empty_upload,
|
&gallery_widgets.empty_upload,
|
||||||
&gallery_widgets.empty_import,
|
&gallery_widgets.empty_import,
|
||||||
);
|
);
|
||||||
|
let ui_duplicates = ui.clone();
|
||||||
|
gallery_widgets
|
||||||
|
.duplicates
|
||||||
|
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
|
||||||
wire_albums(&ui);
|
wire_albums(&ui);
|
||||||
|
wire_places(&ui);
|
||||||
wire_trash(&ui, &trash_widgets);
|
wire_trash(&ui, &trash_widgets);
|
||||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||||
@@ -884,8 +1001,8 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
let icon = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
let icon = gtk4::Image::from_icon_name(APP_ID);
|
||||||
icon.add_css_class("brand-icon");
|
icon.set_pixel_size(24);
|
||||||
brand.append(&icon);
|
brand.append(&icon);
|
||||||
let name = gtk4::Label::new(Some("Proton Drive"));
|
let name = gtk4::Label::new(Some("Proton Drive"));
|
||||||
name.add_css_class("heading");
|
name.add_css_class("heading");
|
||||||
@@ -1027,12 +1144,12 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
|||||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
||||||
}
|
}
|
||||||
// One scope covers the whole photos view, so Refresh reloads whichever of
|
// One scope covers the whole photos view, so Refresh reloads whichever of
|
||||||
// the two — album grid or timeline/album — is actually on screen.
|
// the views — a grid, or the timeline, an album or a place — is on screen.
|
||||||
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
|
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
|
||||||
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
match ui.gallery.content.visible_child_name().as_deref() {
|
||||||
load_albums(ui);
|
Some("albums") => load_albums(ui),
|
||||||
} else {
|
Some("places") => load_places(ui),
|
||||||
load_gallery(ui, false);
|
_ => load_gallery(ui, false),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
||||||
@@ -1321,6 +1438,30 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
|
"This is an unofficial, community-built client. It is not affiliated with, endorsed by, or supported by Proton AG.\n\n“Proton” and “Proton Drive” are trademarks of Proton AG, used here only to describe the service this software connects to. Your use of Proton Drive is governed by Proton's own terms of service and privacy policy.\n\nThis software is provided “as is”, without warranty of any kind. Keep a copy of any data you cannot afford to lose.",
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
dialog.add_legal_section(
|
||||||
|
"GeoNames",
|
||||||
|
None,
|
||||||
|
gtk4::License::Custom,
|
||||||
|
Some(&gettext(
|
||||||
|
"Places are named with data from <a href=\"https://www.geonames.org/\">GeoNames</a>, licensed under <a href=\"https://creativecommons.org/licenses/by/4.0/\">CC BY 4.0</a>.",
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
dialog.add_legal_section(
|
||||||
|
"Natural Earth",
|
||||||
|
None,
|
||||||
|
gtk4::License::Custom,
|
||||||
|
Some(&gettext(
|
||||||
|
"The places map is drawn from <a href=\"https://www.naturalearthdata.com/\">Natural Earth</a> land outlines and country borders, which are in the public domain.",
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
dialog.add_legal_section(
|
||||||
|
"OpenStreetMap",
|
||||||
|
None,
|
||||||
|
gtk4::License::Custom,
|
||||||
|
Some(&gettext(
|
||||||
|
"The street map, when turned on, comes from <a href=\"https://openfreemap.org/\">OpenFreeMap</a> in the <a href=\"https://www.openmaptiles.org/\">OpenMapTiles</a> schema. Map data © <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors, available under the Open Database License.",
|
||||||
|
)),
|
||||||
|
);
|
||||||
dialog.add_legal_section(
|
dialog.add_legal_section(
|
||||||
"proton-sdk-rs",
|
"proton-sdk-rs",
|
||||||
Some("© 2026 Nils Pukropp (contact@narl.io)"),
|
Some("© 2026 Nils Pukropp (contact@narl.io)"),
|
||||||
@@ -1718,89 +1859,6 @@ mod tests {
|
|||||||
assert_eq!(parse_launch(&args(&["--page"])), Launch::Window);
|
assert_eq!(parse_launch(&args(&["--page"])), Launch::Window);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The full width a row occupies, gaps included.
|
|
||||||
fn row_width(widths: &[i32]) -> i32 {
|
|
||||||
widths.iter().sum::<i32>() + TILE_GAP * (widths.len() as i32 - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ratios of a typical mixed day: landscape phone shots, a portrait, a
|
|
||||||
/// square crop.
|
|
||||||
fn mixed() -> Vec<f64> {
|
|
||||||
vec![
|
|
||||||
1.5, 1.5, 0.75, 1.0, 1.33, 1.5, 0.75, 1.78, 1.0, 1.5, 1.5, 1.33,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_full_row_spans_the_content_width() {
|
|
||||||
// The point of justifying: no ragged right margin at any width, at any
|
|
||||||
// zoom, for any mix of shapes.
|
|
||||||
for width in [640, 900, 1000, 1440, 1920, 2560] {
|
|
||||||
let rows = plan_rows(&mixed(), width, ROW_DEFAULT);
|
|
||||||
// The last row is deliberately short, so it is not part of this.
|
|
||||||
for (_, widths) in rows.iter().take(rows.len() - 1) {
|
|
||||||
assert_eq!(row_width(widths), width, "row does not span {width}px");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_tile_keeps_its_photos_shape() {
|
|
||||||
// Each tile is its photo's ratio at the row's height, which is what
|
|
||||||
// makes cropping unnecessary.
|
|
||||||
let ratios = mixed();
|
|
||||||
let rows = plan_rows(&ratios, 1440, ROW_DEFAULT);
|
|
||||||
let (height, widths) = &rows[0];
|
|
||||||
for (width, ratio) in widths.iter().zip(&ratios).take(widths.len() - 1) {
|
|
||||||
let laid_out = *width as f64 / *height as f64;
|
|
||||||
assert!((laid_out - ratio).abs() < 0.02, "{laid_out} is not {ratio}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rows_land_near_the_target_height() {
|
|
||||||
// A row is closed as soon as it no longer fits at the target, so it is
|
|
||||||
// never taller than the target and never far below it.
|
|
||||||
for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] {
|
|
||||||
let rows = plan_rows(&mixed(), 1600, target);
|
|
||||||
for (height, _) in rows.iter().take(rows.len() - 1) {
|
|
||||||
assert!(
|
|
||||||
*height <= target,
|
|
||||||
"{height}px exceeds the {target}px target"
|
|
||||||
);
|
|
||||||
assert!(*height > target / 2, "{height}px is far under {target}px");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_short_day_is_not_stretched_across_the_window() {
|
|
||||||
// Two photos are two photos, not two half-window tiles.
|
|
||||||
let rows = plan_rows(&[1.5, 1.5], 1920, ROW_DEFAULT);
|
|
||||||
assert_eq!(rows.len(), 1);
|
|
||||||
let (height, widths) = &rows[0];
|
|
||||||
assert_eq!(*height, ROW_DEFAULT);
|
|
||||||
assert!(row_width(widths) < 1920 / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zooming_in_puts_fewer_photos_in_a_row() {
|
|
||||||
let small = plan_rows(&mixed(), 1200, ROW_MIN);
|
|
||||||
let big = plan_rows(&mixed(), 1200, ROW_MAX);
|
|
||||||
assert!(small[0].1.len() > big[0].1.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_photo_is_laid_out_exactly_once() {
|
|
||||||
let ratios = mixed();
|
|
||||||
let placed: usize = plan_rows(&ratios, 1000, ROW_DEFAULT)
|
|
||||||
.iter()
|
|
||||||
.map(|(_, widths)| widths.len())
|
|
||||||
.sum();
|
|
||||||
assert_eq!(placed, ratios.len());
|
|
||||||
assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One file of a shot, for the group-switch tests.
|
/// One file of a shot, for the group-switch tests.
|
||||||
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
|
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
|
||||||
PhotoItem {
|
PhotoItem {
|
||||||
@@ -1853,14 +1911,4 @@ mod tests {
|
|||||||
"the video"
|
"the video"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_window_narrower_than_one_photo_still_lays_it_out() {
|
|
||||||
// A tile of at least one px, rather than a zero-width widget or a
|
|
||||||
// division by zero.
|
|
||||||
for width in [0, 40] {
|
|
||||||
let rows = plan_rows(&[1.5, 1.5, 1.5], width, ROW_DEFAULT);
|
|
||||||
assert!(rows.iter().all(|(h, w)| *h > 0 && w.iter().all(|w| *w > 0)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,7 @@ pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
|||||||
/// position. The timeline's filters do not apply to albums, so they go.
|
/// position. The timeline's filters do not apply to albums, so they go.
|
||||||
fn show_album_grid(ui: &Rc<Ui>) {
|
fn show_album_grid(ui: &Rc<Ui>) {
|
||||||
close_album(ui);
|
close_album(ui);
|
||||||
|
close_place(ui);
|
||||||
ui.gallery.filters.set_visible(false);
|
ui.gallery.filters.set_visible(false);
|
||||||
ui.gallery.content.set_visible_child_name("albums");
|
ui.gallery.content.set_visible_child_name("albums");
|
||||||
}
|
}
|
||||||
@@ -468,6 +469,16 @@ fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Filed photos no longer belong in a timeline of unfiled ones.
|
||||||
|
// The daemon's filter drops them too, so the paging offset stays
|
||||||
|
// in step with what is left.
|
||||||
|
if ui.gallery.not_in_album.get() && ui.gallery.album.borrow().is_none() {
|
||||||
|
ui.gallery
|
||||||
|
.selected
|
||||||
|
.borrow_mut()
|
||||||
|
.retain(|uid| !changed.contains(uid));
|
||||||
|
remove_photos(&ui, &changed);
|
||||||
|
}
|
||||||
reload_album_grid(&ui);
|
reload_album_grid(&ui);
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
@@ -572,7 +583,7 @@ fn album_subtitle(album: &AlbumInfo) -> String {
|
|||||||
|
|
||||||
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
||||||
/// same texture cache, same decode pacing as a timeline tile.
|
/// same texture cache, same decode pacing as a timeline tile.
|
||||||
fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||||
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
||||||
picture.set_paintable(Some(texture));
|
picture.set_paintable(Some(texture));
|
||||||
return;
|
return;
|
||||||
@@ -652,6 +663,7 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
close_album(&ui_photos);
|
close_album(&ui_photos);
|
||||||
|
close_place(&ui_photos);
|
||||||
ui_photos.gallery.filters.set_visible(true);
|
ui_photos.gallery.filters.set_visible(true);
|
||||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||||
// The timeline comes back as it was left unless an album took over the
|
// The timeline comes back as it was left unless an album took over the
|
||||||
@@ -664,10 +676,14 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back returns to the grid the album was opened from, scrolled where it
|
// Back returns to the grid the album or place was opened from, scrolled
|
||||||
// was, instead of reloading it.
|
// where it was, instead of reloading it.
|
||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||||
|
if ui_back.gallery.place.borrow().is_some() {
|
||||||
|
show_place_grid(&ui_back);
|
||||||
|
return;
|
||||||
|
}
|
||||||
show_album_grid(&ui_back);
|
show_album_grid(&ui_back);
|
||||||
let count = ui_back.gallery.album_list.borrow().len();
|
let count = ui_back.gallery.album_list.borrow().len();
|
||||||
ui_back.gallery.title.set_subtitle(&ngettext_f(
|
ui_back.gallery.title.set_subtitle(&ngettext_f(
|
||||||
|
|||||||
@@ -2797,6 +2797,15 @@ pub(crate) fn attach_drop(ui: &Rc<Ui>, item: >k4::ListItem, anchor: >k4::Box
|
|||||||
anchor.add_controller(target);
|
anchor.add_controller(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The full-colour icon for an entry: [`icon_base_for`], except that folders
|
||||||
|
/// are Proton's own purple ones while the Proton theme is on.
|
||||||
|
pub(crate) fn icon_for(entry: &DirEntry) -> &'static str {
|
||||||
|
if entry.is_dir && proton_theme_active() {
|
||||||
|
return "pdfs-proton-folder";
|
||||||
|
}
|
||||||
|
icon_base_for(entry)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pick a freedesktop icon base name for an entry from its kind / extension.
|
/// Pick a freedesktop icon base name for an entry from its kind / extension.
|
||||||
/// Callers append `-symbolic` for the column view's small icons.
|
/// Callers append `-symbolic` for the column view's small icons.
|
||||||
pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
|
pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// Side of a copy's thumbnail in the duplicates dialog, in px.
|
||||||
|
const COPY_SIZE: i32 = 150;
|
||||||
|
|
||||||
|
/// Each set's copies with their Keep toggles.
|
||||||
|
type SetToggles = Vec<Vec<(PhotoItem, gtk4::ToggleButton)>>;
|
||||||
|
|
||||||
|
/// How often the dialog asks again while the daemon hashes thumbnails.
|
||||||
|
const HASHING_POLL: std::time::Duration = std::time::Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// What the finder looks for.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Finder {
|
||||||
|
/// Byte-identical copies, going by the server's content hash.
|
||||||
|
Exact,
|
||||||
|
/// Shots that look alike, going by their thumbnails.
|
||||||
|
Similar,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dialog's parts that a search fills in.
|
||||||
|
struct FinderView {
|
||||||
|
ui: Rc<Ui>,
|
||||||
|
status: adw::StatusPage,
|
||||||
|
progress: gtk4::ProgressBar,
|
||||||
|
note: gtk4::Label,
|
||||||
|
stack: gtk4::Stack,
|
||||||
|
trash: gtk4::Button,
|
||||||
|
toolbar: adw::ToolbarView,
|
||||||
|
/// Bumped by every new search and when the dialog closes, so a reply or a
|
||||||
|
/// poll for an older one is dropped.
|
||||||
|
generation: Cell<u32>,
|
||||||
|
/// The Trash button's handler for the review on show.
|
||||||
|
clicked: RefCell<Option<glib::SignalHandlerId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the duplicate finder: every set of photos stored more than once, side
|
||||||
|
/// by side, with the copy worth keeping ticked. The copies left unticked go to
|
||||||
|
/// Trash — one file each, never the rest of their shot. The Similar switch
|
||||||
|
/// looks for shots that look alike instead.
|
||||||
|
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
||||||
|
let progress = gtk4::ProgressBar::builder()
|
||||||
|
.show_text(true)
|
||||||
|
.width_request(280)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
let note = gtk4::Label::builder()
|
||||||
|
.wrap(true)
|
||||||
|
.justify(gtk4::Justification::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
note.add_css_class("dim-label");
|
||||||
|
let status_extra = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
status_extra.append(&progress);
|
||||||
|
status_extra.append(¬e);
|
||||||
|
let status = adw::StatusPage::builder()
|
||||||
|
.icon_name("edit-copy-symbolic")
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&status_extra)
|
||||||
|
.build();
|
||||||
|
status.add_css_class("compact");
|
||||||
|
|
||||||
|
let stack = gtk4::Stack::new();
|
||||||
|
stack.add_named(&status, Some("status"));
|
||||||
|
|
||||||
|
let trash = gtk4::Button::builder()
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.sensitive(false)
|
||||||
|
.build();
|
||||||
|
trash.add_css_class("destructive-action");
|
||||||
|
trash.add_css_class("pill");
|
||||||
|
let bottom = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
|
bottom.add_css_class("toolbar");
|
||||||
|
bottom.set_halign(gtk4::Align::End);
|
||||||
|
bottom.append(&trash);
|
||||||
|
|
||||||
|
let exact = gtk4::ToggleButton::builder()
|
||||||
|
.label(pgettext("duplicates", "Exact"))
|
||||||
|
.tooltip_text(gettext("Photos stored more than once"))
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
let similar = gtk4::ToggleButton::builder()
|
||||||
|
.label(pgettext("duplicates", "Similar"))
|
||||||
|
.tooltip_text(gettext("Photos that look alike"))
|
||||||
|
.group(&exact)
|
||||||
|
.build();
|
||||||
|
let switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
|
switch.add_css_class("linked");
|
||||||
|
switch.append(&exact);
|
||||||
|
switch.append(&similar);
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
header.set_title_widget(Some(&switch));
|
||||||
|
|
||||||
|
let toolbar = adw::ToolbarView::new();
|
||||||
|
toolbar.add_top_bar(&header);
|
||||||
|
toolbar.set_content(Some(&stack));
|
||||||
|
toolbar.add_bottom_bar(&bottom);
|
||||||
|
toolbar.set_reveal_bottom_bars(false);
|
||||||
|
|
||||||
|
let dialog = adw::Dialog::builder()
|
||||||
|
.title(gettext("Duplicates"))
|
||||||
|
.content_width(760)
|
||||||
|
.content_height(640)
|
||||||
|
.child(&toolbar)
|
||||||
|
.build();
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
|
||||||
|
let view = Rc::new(FinderView {
|
||||||
|
ui: ui.clone(),
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
note,
|
||||||
|
stack,
|
||||||
|
trash,
|
||||||
|
toolbar,
|
||||||
|
generation: Cell::new(0),
|
||||||
|
clicked: RefCell::new(None),
|
||||||
|
});
|
||||||
|
let view_closed = view.clone();
|
||||||
|
dialog.connect_closed(move |_| {
|
||||||
|
view_closed
|
||||||
|
.generation
|
||||||
|
.set(view_closed.generation.get().wrapping_add(1));
|
||||||
|
});
|
||||||
|
let view_switch = view.clone();
|
||||||
|
similar.connect_toggled(move |similar| {
|
||||||
|
let finder = if similar.is_active() {
|
||||||
|
Finder::Similar
|
||||||
|
} else {
|
||||||
|
Finder::Exact
|
||||||
|
};
|
||||||
|
search(&view_switch, finder);
|
||||||
|
});
|
||||||
|
search(&view, Finder::Exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop what the dialog shows and look again, for `finder`.
|
||||||
|
fn search(view: &Rc<FinderView>, finder: Finder) {
|
||||||
|
let generation = view.generation.get().wrapping_add(1);
|
||||||
|
view.generation.set(generation);
|
||||||
|
if let Some(review) = view.stack.child_by_name("review") {
|
||||||
|
view.stack.remove(&review);
|
||||||
|
}
|
||||||
|
if let Some(handler) = view.clicked.take() {
|
||||||
|
view.trash.disconnect(handler);
|
||||||
|
}
|
||||||
|
view.toolbar.set_reveal_bottom_bars(false);
|
||||||
|
view.status.set_icon_name(Some("edit-copy-symbolic"));
|
||||||
|
view.status.set_title(&match finder {
|
||||||
|
Finder::Exact => gettext("Looking for duplicates…"),
|
||||||
|
Finder::Similar => gettext("Looking for similar photos…"),
|
||||||
|
});
|
||||||
|
view.status.set_description(None);
|
||||||
|
view.progress.set_visible(false);
|
||||||
|
view.note.set_visible(false);
|
||||||
|
view.stack.set_visible_child_name("status");
|
||||||
|
ask(view, finder, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the daemon for `finder`'s sets and show them, or, while it is still
|
||||||
|
/// hashing thumbnails, how far it has got — and ask again in a moment.
|
||||||
|
fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
|
||||||
|
let request = match finder {
|
||||||
|
Finder::Exact => Request::PhotoDuplicates,
|
||||||
|
Finder::Similar => Request::PhotoSimilar,
|
||||||
|
};
|
||||||
|
let rx = spawn_request(view.ui.dirs.control_socket(), request);
|
||||||
|
let view = view.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let reply = rx.recv().await;
|
||||||
|
if view.generation.get() != generation {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let failed = match finder {
|
||||||
|
Finder::Exact => gettext("Couldn't look for duplicates"),
|
||||||
|
Finder::Similar => gettext("Couldn't look for similar photos"),
|
||||||
|
};
|
||||||
|
let (sets, uncompared) = match reply {
|
||||||
|
Ok(Ok(Response::PhotoDuplicates { sets })) => (sets, 0),
|
||||||
|
Ok(Ok(Response::PhotoSimilar {
|
||||||
|
hashing: Some(MappingProgress { done, total }),
|
||||||
|
..
|
||||||
|
})) => {
|
||||||
|
view.status.set_title(&gettext("Comparing photos…"));
|
||||||
|
view.status.set_description(Some(&gettext(
|
||||||
|
"Photos are compared by their thumbnails. The first time takes a while.",
|
||||||
|
)));
|
||||||
|
view.progress.set_fraction(if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
done as f64 / total as f64
|
||||||
|
});
|
||||||
|
view.progress.set_text(Some(&gettext_f(
|
||||||
|
// Translators: {done} and {total} are numbers of photos.
|
||||||
|
"{done} of {total}",
|
||||||
|
&[("done", &thousands(done)), ("total", &thousands(total))],
|
||||||
|
)));
|
||||||
|
view.progress.set_visible(true);
|
||||||
|
glib::timeout_add_local_once(HASHING_POLL, move || {
|
||||||
|
if view.generation.get() == generation {
|
||||||
|
ask(&view, finder, generation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::PhotoSimilar {
|
||||||
|
sets,
|
||||||
|
hashing: None,
|
||||||
|
uncompared,
|
||||||
|
})) => (sets, uncompared),
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||||
|
view.status.set_title(&failed);
|
||||||
|
view.status.set_description(Some(&message));
|
||||||
|
view.progress.set_visible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||||
|
view.status.set_title(&failed);
|
||||||
|
view.status
|
||||||
|
.set_description(Some(&gettext("The mount service didn't respond.")));
|
||||||
|
view.progress.set_visible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
view.progress.set_visible(false);
|
||||||
|
if sets.is_empty() {
|
||||||
|
view.status.set_icon_name(Some("emblem-ok-symbolic"));
|
||||||
|
match finder {
|
||||||
|
Finder::Exact => {
|
||||||
|
view.status.set_title(&gettext("No duplicates"));
|
||||||
|
view.status.set_description(Some(&gettext(
|
||||||
|
"Every photo in your library is stored once.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Finder::Similar => {
|
||||||
|
view.status.set_title(&gettext("No similar photos"));
|
||||||
|
view.status.set_description(Some(&gettext(
|
||||||
|
"No two photos in your library look alike.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uncompared > 0 {
|
||||||
|
view.note.set_label(&uncompared_note(uncompared));
|
||||||
|
view.note.set_visible(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (review, handler) = duplicate_review(&view.ui, sets, &view.trash, finder, uncompared);
|
||||||
|
view.clicked.replace(Some(handler));
|
||||||
|
view.stack.add_named(&review, Some("review"));
|
||||||
|
view.stack.set_visible_child_name("review");
|
||||||
|
view.toolbar.set_reveal_bottom_bars(true);
|
||||||
|
schedule_thumbs(&view.ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Says how many photos the similar finder left out for want of a thumbnail.
|
||||||
|
fn uncompared_note(n: usize) -> String {
|
||||||
|
ngettext_f(
|
||||||
|
"{n} photo couldn't be compared because it has no thumbnail yet.",
|
||||||
|
"{n} photos couldn't be compared because they have no thumbnail yet.",
|
||||||
|
n as u64,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The review list: one section per set, each photo a thumbnail with a Keep
|
||||||
|
/// toggle. Wires `trash` to move every photo not kept to Trash, and returns
|
||||||
|
/// that handler so a new search can take it off again.
|
||||||
|
///
|
||||||
|
/// Exact copies start with only the first of each set kept. Similar photos
|
||||||
|
/// all start kept: photos that look alike can still be different shots.
|
||||||
|
fn duplicate_review(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
sets: Vec<Vec<PhotoItem>>,
|
||||||
|
trash: >k4::Button,
|
||||||
|
finder: Finder,
|
||||||
|
uncompared: usize,
|
||||||
|
) -> (gtk4::Widget, glib::SignalHandlerId) {
|
||||||
|
let intro = gtk4::Label::builder()
|
||||||
|
.label(match finder {
|
||||||
|
Finder::Exact => gettext(
|
||||||
|
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
|
||||||
|
),
|
||||||
|
Finder::Similar => gettext(
|
||||||
|
"These photos look alike. They can be copies of one photo or different shots of one moment, so all of them are kept until you untick them. The photos you don't keep move to Trash, where you can restore them.",
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.wrap(true)
|
||||||
|
.xalign(0.0)
|
||||||
|
.build();
|
||||||
|
intro.add_css_class("dim-label");
|
||||||
|
|
||||||
|
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
|
content.set_margin_top(12);
|
||||||
|
content.set_margin_bottom(12);
|
||||||
|
content.set_margin_start(18);
|
||||||
|
content.set_margin_end(18);
|
||||||
|
content.append(&intro);
|
||||||
|
if uncompared > 0 {
|
||||||
|
let note = gtk4::Label::builder()
|
||||||
|
.label(uncompared_note(uncompared))
|
||||||
|
.wrap(true)
|
||||||
|
.xalign(0.0)
|
||||||
|
.build();
|
||||||
|
note.add_css_class("dim-label");
|
||||||
|
content.append(¬e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every copy's toggle, by set, so the button can count what goes.
|
||||||
|
let toggles: Rc<RefCell<SetToggles>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
for set in sets {
|
||||||
|
let Some(first) = set.first() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let date = section_heading(first.capture_time, Grouping::Day);
|
||||||
|
let title = match finder {
|
||||||
|
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
|
||||||
|
Finder::Exact => ngettext_f(
|
||||||
|
"{date}, {n} copy",
|
||||||
|
"{date}, {n} copies",
|
||||||
|
set.len() as u64,
|
||||||
|
&[("date", &date)],
|
||||||
|
),
|
||||||
|
// Translators: heading of one set of photos that look alike; {date} is the day the first was taken.
|
||||||
|
Finder::Similar => ngettext_f(
|
||||||
|
"{date}, {n} photo",
|
||||||
|
"{date}, {n} photos",
|
||||||
|
set.len() as u64,
|
||||||
|
&[("date", &date)],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let heading = gtk4::Label::builder().label(title).xalign(0.0).build();
|
||||||
|
heading.add_css_class("heading");
|
||||||
|
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||||
|
let mut set_toggles = Vec::new();
|
||||||
|
for (index, photo) in set.into_iter().enumerate() {
|
||||||
|
let (card, keep) = copy_card(ui, &photo, index == 0 || finder == Finder::Similar);
|
||||||
|
row.append(&card);
|
||||||
|
set_toggles.push((photo, keep));
|
||||||
|
}
|
||||||
|
let row_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.propagate_natural_height(true)
|
||||||
|
.child(&row)
|
||||||
|
.build();
|
||||||
|
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||||
|
section.append(&heading);
|
||||||
|
section.append(&row_scroll);
|
||||||
|
content.append(§ion);
|
||||||
|
toggles.borrow_mut().push(set_toggles);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sync = {
|
||||||
|
let toggles = toggles.clone();
|
||||||
|
let trash = trash.clone();
|
||||||
|
move || {
|
||||||
|
let n = doomed(&toggles.borrow()).len();
|
||||||
|
trash.set_label(&match finder {
|
||||||
|
Finder::Exact => ngettext_f(
|
||||||
|
"Move {n} copy to Trash",
|
||||||
|
"Move {n} copies to Trash",
|
||||||
|
n as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
Finder::Similar => ngettext_f(
|
||||||
|
"Move {n} photo to Trash",
|
||||||
|
"Move {n} photos to Trash",
|
||||||
|
n as u64,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
trash.set_sensitive(n > 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
for set in toggles.borrow().iter() {
|
||||||
|
for (_, keep) in set {
|
||||||
|
let toggles = toggles.clone();
|
||||||
|
let sync = sync.clone();
|
||||||
|
keep.connect_toggled(move |keep| {
|
||||||
|
// A set always keeps a copy: unticking the last one would trash
|
||||||
|
// the photo itself, which is not what removing duplicates means.
|
||||||
|
if !keep.is_active()
|
||||||
|
&& toggles.borrow().iter().any(|set| {
|
||||||
|
set.iter().any(|(_, t)| t == keep)
|
||||||
|
&& set.iter().all(|(_, t)| !t.is_active())
|
||||||
|
})
|
||||||
|
{
|
||||||
|
keep.set_active(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ui_trash = ui.clone();
|
||||||
|
let handler = trash.connect_clicked(move |button| {
|
||||||
|
let uids = doomed(&toggles.borrow());
|
||||||
|
if uids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// An exact copy is one file of its shot; a similar photo stands for
|
||||||
|
// its whole shot, raw and all.
|
||||||
|
trash_photos(&ui_trash, uids, finder == Finder::Exact);
|
||||||
|
if let Some(dialog) = button
|
||||||
|
.ancestor(adw::Dialog::static_type())
|
||||||
|
.and_downcast::<adw::Dialog>()
|
||||||
|
{
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&content)
|
||||||
|
.build();
|
||||||
|
(scroll.upcast(), handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The uids of every copy not kept.
|
||||||
|
fn doomed(toggles: &SetToggles) -> Vec<String> {
|
||||||
|
toggles
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|(_, keep)| !keep.is_active())
|
||||||
|
.map(|(photo, _)| photo.uid.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One copy: its thumbnail, its file name, and whether to keep it.
|
||||||
|
fn copy_card(ui: &Rc<Ui>, photo: &PhotoItem, keep: bool) -> (gtk4::Box, gtk4::ToggleButton) {
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.can_shrink(true)
|
||||||
|
.build();
|
||||||
|
let placeholder = gtk4::Image::builder()
|
||||||
|
.icon_name("image-x-generic-symbolic")
|
||||||
|
.pixel_size(24)
|
||||||
|
.build();
|
||||||
|
placeholder.add_css_class("photo-placeholder");
|
||||||
|
let overlay = gtk4::Overlay::new();
|
||||||
|
overlay.set_size_request(COPY_SIZE, COPY_SIZE);
|
||||||
|
overlay.set_child(Some(&placeholder));
|
||||||
|
overlay.add_overlay(&picture);
|
||||||
|
overlay.set_overflow(gtk4::Overflow::Hidden);
|
||||||
|
overlay.add_css_class("memory-cover");
|
||||||
|
want_thumb(ui, photo, &picture);
|
||||||
|
|
||||||
|
let name = photo.name.clone().unwrap_or_default();
|
||||||
|
let label = gtk4::Label::builder()
|
||||||
|
.label(&name)
|
||||||
|
.tooltip_text(&name)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||||
|
.max_width_chars(18)
|
||||||
|
.build();
|
||||||
|
label.add_css_class("caption");
|
||||||
|
|
||||||
|
let toggle = gtk4::ToggleButton::builder()
|
||||||
|
.label(gettext("Keep"))
|
||||||
|
.active(keep)
|
||||||
|
.build();
|
||||||
|
toggle.add_css_class("pill");
|
||||||
|
|
||||||
|
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||||
|
card.set_size_request(COPY_SIZE, -1);
|
||||||
|
card.append(&overlay);
|
||||||
|
card.append(&label);
|
||||||
|
card.append(&toggle);
|
||||||
|
(card, toggle)
|
||||||
|
}
|
||||||
@@ -52,9 +52,8 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
|||||||
login_status.add_css_class("dim-label");
|
login_status.add_css_class("dim-label");
|
||||||
|
|
||||||
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||||
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
let logo = gtk4::Image::from_icon_name(APP_ID);
|
||||||
logo.set_pixel_size(64);
|
logo.set_pixel_size(96);
|
||||||
logo.add_css_class("brand-icon");
|
|
||||||
let title = gtk4::Label::new(Some("Proton Drive"));
|
let title = gtk4::Label::new(Some("Proton Drive"));
|
||||||
title.add_css_class("brand-title");
|
title.add_css_class("brand-title");
|
||||||
header.append(&logo);
|
header.append(&logo);
|
||||||
|
|||||||
@@ -0,0 +1,877 @@
|
|||||||
|
//! The online street map under the Places map: vector tiles from OpenFreeMap,
|
||||||
|
//! fetched and drawn on a worker thread, and handed to the map as finished
|
||||||
|
//! images.
|
||||||
|
//!
|
||||||
|
//! It is off unless turned on in Preferences, since every tile fetched tells
|
||||||
|
//! OpenFreeMap which part of the world is being looked at. OpenFreeMap needs
|
||||||
|
//! no account or key, and its tiles are OpenStreetMap data in the
|
||||||
|
//! OpenMapTiles schema; both have to be credited wherever the map shows.
|
||||||
|
//!
|
||||||
|
//! Tiles are kept on disk under the cache directory, per build of the tile
|
||||||
|
//! set. A build never changes, so a cached tile is good until OpenFreeMap
|
||||||
|
//! publishes the next one, and then the old build's directory goes. Town and
|
||||||
|
//! country names are not baked into the images: they come back as [`Label`]s
|
||||||
|
//! for the map to set on top, where it can keep them upright, sharp and apart
|
||||||
|
//! from each other.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gtk4::cairo;
|
||||||
|
|
||||||
|
use super::mvt::{self, Kind, Layer};
|
||||||
|
|
||||||
|
/// Where OpenFreeMap describes its current tile set.
|
||||||
|
const TILEJSON_URL: &str = "https://tiles.openfreemap.org/planet";
|
||||||
|
|
||||||
|
/// The deepest zoom OpenFreeMap has tiles for. Closer in, a tile is drawn
|
||||||
|
/// from part of its ancestor at this zoom.
|
||||||
|
pub(crate) const DATA_MAX_ZOOM: u8 = 14;
|
||||||
|
|
||||||
|
/// A tile's edge in logical px at the zoom it was made for.
|
||||||
|
pub(crate) const TILE_EDGE: f64 = 256.0;
|
||||||
|
|
||||||
|
/// How many tiles are fetched at once.
|
||||||
|
const PARALLEL_FETCHES: usize = 6;
|
||||||
|
|
||||||
|
/// How long one fetch may take.
|
||||||
|
const FETCH_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// Past this size the tile cache is emptied rather than left to grow.
|
||||||
|
const DISK_BUDGET: u64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// One tile of the Web Mercator grid: at zoom `z` the world is `2^z` tiles
|
||||||
|
/// wide and tall, counted from the north-west.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) struct TileKey {
|
||||||
|
pub(crate) z: u8,
|
||||||
|
pub(crate) x: u32,
|
||||||
|
pub(crate) y: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TileKey {
|
||||||
|
pub(crate) fn parent(self) -> Option<Self> {
|
||||||
|
(self.z > 0).then(|| Self {
|
||||||
|
z: self.z - 1,
|
||||||
|
x: self.x / 2,
|
||||||
|
y: self.y / 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile whose data this one is drawn from.
|
||||||
|
fn data(self) -> Self {
|
||||||
|
let mut key = self;
|
||||||
|
while key.z > DATA_MAX_ZOOM {
|
||||||
|
key = key.parent().unwrap_or(key);
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colours of the map, for a light or a dark window.
|
||||||
|
pub(crate) struct Palette {
|
||||||
|
pub(crate) land: u32,
|
||||||
|
pub(crate) water: u32,
|
||||||
|
wood: u32,
|
||||||
|
grass: u32,
|
||||||
|
ice: u32,
|
||||||
|
sand: u32,
|
||||||
|
residential: u32,
|
||||||
|
industrial: u32,
|
||||||
|
building: u32,
|
||||||
|
motorway: u32,
|
||||||
|
primary: u32,
|
||||||
|
road: u32,
|
||||||
|
casing: u32,
|
||||||
|
rail: u32,
|
||||||
|
path: u32,
|
||||||
|
pub(crate) border: u32,
|
||||||
|
state_border: u32,
|
||||||
|
pub(crate) coast: u32,
|
||||||
|
pub(crate) text: u32,
|
||||||
|
pub(crate) halo: u32,
|
||||||
|
pub(crate) water_text: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const LIGHT: Palette = Palette {
|
||||||
|
land: 0xf4f2ee,
|
||||||
|
water: 0xb3cde3,
|
||||||
|
wood: 0xd3e5c5,
|
||||||
|
grass: 0xdcebcf,
|
||||||
|
ice: 0xf2f6f9,
|
||||||
|
sand: 0xefe7d2,
|
||||||
|
residential: 0xebe7e2,
|
||||||
|
industrial: 0xebe4e8,
|
||||||
|
building: 0xdcd5ce,
|
||||||
|
motorway: 0xf2c98f,
|
||||||
|
primary: 0xfbe4ad,
|
||||||
|
road: 0xffffff,
|
||||||
|
casing: 0xd2cbc1,
|
||||||
|
rail: 0xb5afa8,
|
||||||
|
path: 0xbfae9c,
|
||||||
|
border: 0x9d94b5,
|
||||||
|
state_border: 0xc2bbd2,
|
||||||
|
coast: 0x9fbbd4,
|
||||||
|
text: 0x34313d,
|
||||||
|
halo: 0xffffff,
|
||||||
|
water_text: 0x4f7398,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) const DARK: Palette = Palette {
|
||||||
|
land: 0x24222d,
|
||||||
|
water: 0x141c2b,
|
||||||
|
wood: 0x212c26,
|
||||||
|
grass: 0x232d27,
|
||||||
|
ice: 0x2c313a,
|
||||||
|
sand: 0x2d2b27,
|
||||||
|
residential: 0x292733,
|
||||||
|
industrial: 0x2b2833,
|
||||||
|
building: 0x322f3c,
|
||||||
|
motorway: 0x7a6647,
|
||||||
|
primary: 0x625a4b,
|
||||||
|
road: 0x46434f,
|
||||||
|
casing: 0x1a1822,
|
||||||
|
rail: 0x4e4a5a,
|
||||||
|
path: 0x3f3b49,
|
||||||
|
border: 0x857ca0,
|
||||||
|
state_border: 0x5c556f,
|
||||||
|
coast: 0x2c3b52,
|
||||||
|
text: 0xe7e4ef,
|
||||||
|
halo: 0x16141c,
|
||||||
|
water_text: 0x86a6cb,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn palette(dark: bool) -> &'static Palette {
|
||||||
|
if dark { &DARK } else { &LIGHT }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_rgb(cr: &cairo::Context, color: u32, alpha: f64) {
|
||||||
|
cr.set_source_rgba(
|
||||||
|
f64::from((color >> 16) & 0xff) / 255.0,
|
||||||
|
f64::from((color >> 8) & 0xff) / 255.0,
|
||||||
|
f64::from(color & 0xff) / 255.0,
|
||||||
|
alpha,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of place a name stands for, which sets how it is written and
|
||||||
|
/// which names win when they would overlap.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub(crate) enum LabelClass {
|
||||||
|
Country,
|
||||||
|
State,
|
||||||
|
City,
|
||||||
|
Town,
|
||||||
|
Village,
|
||||||
|
Suburb,
|
||||||
|
Hamlet,
|
||||||
|
Water,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LabelClass {
|
||||||
|
fn from_place(class: &str) -> Option<Self> {
|
||||||
|
Some(match class {
|
||||||
|
"country" => Self::Country,
|
||||||
|
"state" | "province" => Self::State,
|
||||||
|
"city" => Self::City,
|
||||||
|
"town" => Self::Town,
|
||||||
|
"village" => Self::Village,
|
||||||
|
"suburb" | "quarter" => Self::Suburb,
|
||||||
|
"hamlet" | "neighbourhood" | "isolated_dwelling" => Self::Hamlet,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zoom from which such names are shown at all.
|
||||||
|
pub(crate) fn min_zoom(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Country | Self::Water | Self::City => 0,
|
||||||
|
Self::State => 4,
|
||||||
|
Self::Town => 8,
|
||||||
|
Self::Village => 11,
|
||||||
|
Self::Suburb => 12,
|
||||||
|
Self::Hamlet => 14,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The zoom past which such names are left out again: a country's name
|
||||||
|
/// is no help once its towns are.
|
||||||
|
pub(crate) fn max_zoom(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Country => 7,
|
||||||
|
Self::State => 9,
|
||||||
|
_ => u8::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name to set on the map, at its place in world space (0..1 both ways).
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct Label {
|
||||||
|
pub(crate) x: f64,
|
||||||
|
pub(crate) y: f64,
|
||||||
|
pub(crate) text: String,
|
||||||
|
pub(crate) class: LabelClass,
|
||||||
|
/// Lower is more important, within a class.
|
||||||
|
pub(crate) rank: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finished tile: premultiplied ARGB pixels, as cairo keeps them.
|
||||||
|
pub(crate) struct Raster {
|
||||||
|
pub(crate) key: TileKey,
|
||||||
|
pub(crate) dark: bool,
|
||||||
|
pub(crate) edge: i32,
|
||||||
|
pub(crate) pixels: Vec<u8>,
|
||||||
|
pub(crate) labels: Vec<Label>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Raster {
|
||||||
|
pub(crate) fn surface(self) -> Option<cairo::ImageSurface> {
|
||||||
|
let stride = self.edge * 4;
|
||||||
|
cairo::ImageSurface::create_for_data(
|
||||||
|
self.pixels,
|
||||||
|
cairo::Format::ARgb32,
|
||||||
|
self.edge,
|
||||||
|
self.edge,
|
||||||
|
stride,
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum Done {
|
||||||
|
Tile(Raster),
|
||||||
|
/// Nothing came: no network, or no such tile. The outline shows there.
|
||||||
|
Failed(TileKey),
|
||||||
|
/// The tile went off screen before its turn.
|
||||||
|
Dropped(TileKey),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Job {
|
||||||
|
key: TileKey,
|
||||||
|
dark: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The worker thread making tiles. Dropping it stops the thread once the
|
||||||
|
/// fetches under way are done.
|
||||||
|
pub(crate) struct Tiles {
|
||||||
|
jobs: async_channel::Sender<Job>,
|
||||||
|
wanted: Arc<Mutex<HashSet<TileKey>>>,
|
||||||
|
/// Device pixels per logical px the tiles are made at.
|
||||||
|
pub(crate) scale: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tiles {
|
||||||
|
/// Start the worker, caching under `cache_dir`, drawing names in
|
||||||
|
/// `language` where the map has them. Finished tiles arrive on the
|
||||||
|
/// returned channel.
|
||||||
|
pub(crate) fn start(
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
language: String,
|
||||||
|
scale: i32,
|
||||||
|
) -> (Self, async_channel::Receiver<Done>) {
|
||||||
|
let (jobs, job_rx) = async_channel::unbounded();
|
||||||
|
let (done_tx, done_rx) = async_channel::unbounded();
|
||||||
|
let wanted = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let worker = Worker {
|
||||||
|
dir: cache_dir,
|
||||||
|
language,
|
||||||
|
scale,
|
||||||
|
wanted: wanted.clone(),
|
||||||
|
done: done_tx,
|
||||||
|
};
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("map-tiles".into())
|
||||||
|
.spawn(move || worker.run(job_rx));
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
tracing::warn!("map tiles: cannot start the worker: {e}");
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
jobs,
|
||||||
|
wanted,
|
||||||
|
scale,
|
||||||
|
},
|
||||||
|
done_rx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn request(&self, key: TileKey, dark: bool) {
|
||||||
|
let _ = self.jobs.try_send(Job { key, dark });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tiles on screen now. A queued tile that isn't is skipped.
|
||||||
|
pub(crate) fn set_wanted(&self, keys: HashSet<TileKey>) {
|
||||||
|
if let Ok(mut wanted) = self.wanted.lock() {
|
||||||
|
*wanted = keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Worker {
|
||||||
|
dir: PathBuf,
|
||||||
|
language: String,
|
||||||
|
scale: i32,
|
||||||
|
wanted: Arc<Mutex<HashSet<TileKey>>>,
|
||||||
|
done: async_channel::Sender<Done>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Worker {
|
||||||
|
fn run(self, jobs: async_channel::Receiver<Job>) {
|
||||||
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(runtime) => runtime,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("map tiles: no runtime: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.user_agent(concat!(
|
||||||
|
"proton-drive-linux/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" (",
|
||||||
|
env!("CARGO_PKG_REPOSITORY"),
|
||||||
|
")"
|
||||||
|
))
|
||||||
|
.timeout(FETCH_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("map tiles: no HTTP client: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let worker = Arc::new(self);
|
||||||
|
runtime.block_on(async move {
|
||||||
|
let source = Arc::new(tokio::sync::Mutex::new(None::<String>));
|
||||||
|
let slots = Arc::new(tokio::sync::Semaphore::new(PARALLEL_FETCHES));
|
||||||
|
while let Ok(job) = jobs.recv().await {
|
||||||
|
let Ok(permit) = slots.clone().acquire_owned().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let (worker, client, source) = (worker.clone(), client.clone(), source.clone());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let done = worker.make(&client, &source, job).await;
|
||||||
|
let _ = worker.done.send(done).await;
|
||||||
|
drop(permit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Let the tiles under way finish before the runtime goes.
|
||||||
|
let _ = slots.acquire_many(PARALLEL_FETCHES as u32).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn make(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
source: &tokio::sync::Mutex<Option<String>>,
|
||||||
|
job: Job,
|
||||||
|
) -> Done {
|
||||||
|
let key = job.key;
|
||||||
|
let still_wanted = self.wanted.lock().map(|w| w.contains(&key)).unwrap_or(true);
|
||||||
|
if !still_wanted {
|
||||||
|
return Done::Dropped(key);
|
||||||
|
}
|
||||||
|
let Some(data) = self.load(client, source, key.data()).await else {
|
||||||
|
return Done::Failed(key);
|
||||||
|
};
|
||||||
|
let worker = self.clone();
|
||||||
|
let made = tokio::task::spawn_blocking(move || {
|
||||||
|
let layers = mvt::decode(&data)?;
|
||||||
|
render(&layers, key, job.dark, worker.scale, &worker.language)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match made {
|
||||||
|
Ok(Some(raster)) => Done::Tile(raster),
|
||||||
|
_ => Done::Failed(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile's data, from the disk cache or else from OpenFreeMap.
|
||||||
|
async fn load(
|
||||||
|
&self,
|
||||||
|
client: &reqwest::Client,
|
||||||
|
source: &tokio::sync::Mutex<Option<String>>,
|
||||||
|
key: TileKey,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
let template = {
|
||||||
|
let mut source = source.lock().await;
|
||||||
|
if source.is_none() {
|
||||||
|
*source = self.tile_source(client).await;
|
||||||
|
}
|
||||||
|
source.clone()?
|
||||||
|
};
|
||||||
|
let path = self
|
||||||
|
.build_dir(&template)?
|
||||||
|
.join(key.z.to_string())
|
||||||
|
.join(key.x.to_string())
|
||||||
|
.join(format!("{}.pbf", key.y));
|
||||||
|
if let Ok(data) = tokio::fs::read(&path).await {
|
||||||
|
return Some(data);
|
||||||
|
}
|
||||||
|
let url = template
|
||||||
|
.replace("{z}", &key.z.to_string())
|
||||||
|
.replace("{x}", &key.x.to_string())
|
||||||
|
.replace("{y}", &key.y.to_string());
|
||||||
|
let reply = client.get(&url).send().await.ok()?;
|
||||||
|
if !reply.status().is_success() {
|
||||||
|
tracing::debug!("map tile {url}: {}", reply.status());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let data = reply.bytes().await.ok()?.to_vec();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::write(&path, &data).await;
|
||||||
|
Some(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL template of OpenFreeMap's current build, remembered on disk so
|
||||||
|
/// the cached tiles still show without a network.
|
||||||
|
async fn tile_source(&self, client: &reqwest::Client) -> Option<String> {
|
||||||
|
let saved = self.dir.join("tiles.json");
|
||||||
|
let fetched = async {
|
||||||
|
let reply = client.get(TILEJSON_URL).send().await.ok()?;
|
||||||
|
if !reply.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
reply.bytes().await.ok()
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let (json, fresh) = match fetched {
|
||||||
|
Some(json) => (json.to_vec(), true),
|
||||||
|
None => (tokio::fs::read(&saved).await.ok()?, false),
|
||||||
|
};
|
||||||
|
let template = tile_template(&json)?;
|
||||||
|
if fresh {
|
||||||
|
let _ = tokio::fs::create_dir_all(&self.dir).await;
|
||||||
|
let _ = tokio::fs::write(&saved, &json).await;
|
||||||
|
let (dir, build) = (self.dir.clone(), build_name(&template)?);
|
||||||
|
let _ = tokio::task::spawn_blocking(move || prune(&dir, &build)).await;
|
||||||
|
}
|
||||||
|
Some(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_dir(&self, template: &str) -> Option<PathBuf> {
|
||||||
|
Some(self.dir.join(build_name(template)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first tile URL template in a TileJSON document.
|
||||||
|
fn tile_template(json: &[u8]) -> Option<String> {
|
||||||
|
let doc: serde_json::Value = serde_json::from_slice(json).ok()?;
|
||||||
|
let template = doc.get("tiles")?.get(0)?.as_str()?;
|
||||||
|
template
|
||||||
|
.starts_with("https://")
|
||||||
|
.then(|| template.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile set's build, the path segment before `/{z}`: a safe directory
|
||||||
|
/// name, or `None`.
|
||||||
|
fn build_name(template: &str) -> Option<String> {
|
||||||
|
let head = template.split("/{z}").next()?;
|
||||||
|
let name = head.rsplit('/').next()?;
|
||||||
|
let safe = !name.is_empty()
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||||
|
&& name != "."
|
||||||
|
&& name != "..";
|
||||||
|
safe.then(|| name.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the cached builds other than `keep`, and `keep` itself once it
|
||||||
|
/// holds more than [`DISK_BUDGET`].
|
||||||
|
fn prune(dir: &Path, keep: &str) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stale = entry.file_name() != keep || dir_size(&path) > DISK_BUDGET;
|
||||||
|
if stale && let Err(e) = std::fs::remove_dir_all(&path) {
|
||||||
|
tracing::debug!("map tiles: cannot remove {}: {e}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir_size(dir: &Path) -> u64 {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| match entry.metadata() {
|
||||||
|
Ok(meta) if meta.is_dir() => dir_size(&entry.path()),
|
||||||
|
Ok(meta) => meta.len(),
|
||||||
|
Err(_) => 0,
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw tile `key` from the layers of its data tile, at `scale` device px
|
||||||
|
/// per logical px, and gather the names on it.
|
||||||
|
fn render(
|
||||||
|
layers: &[Layer],
|
||||||
|
key: TileKey,
|
||||||
|
dark: bool,
|
||||||
|
scale: i32,
|
||||||
|
language: &str,
|
||||||
|
) -> Option<Raster> {
|
||||||
|
let edge = (TILE_EDGE as i32) * scale.max(1);
|
||||||
|
let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, edge, edge).ok()?;
|
||||||
|
let labels = {
|
||||||
|
let cr = cairo::Context::new(&surface).ok()?;
|
||||||
|
cr.scale(f64::from(scale), f64::from(scale));
|
||||||
|
let painter = Painter {
|
||||||
|
cr: &cr,
|
||||||
|
key,
|
||||||
|
data: key.data(),
|
||||||
|
colors: palette(dark),
|
||||||
|
};
|
||||||
|
painter.paint(layers);
|
||||||
|
gather_labels(layers, key.data(), language)
|
||||||
|
};
|
||||||
|
surface.flush();
|
||||||
|
let pixels = surface.take_data().ok()?.to_vec();
|
||||||
|
Some(Raster {
|
||||||
|
key,
|
||||||
|
dark,
|
||||||
|
edge,
|
||||||
|
pixels,
|
||||||
|
labels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Painter<'a> {
|
||||||
|
cr: &'a cairo::Context,
|
||||||
|
key: TileKey,
|
||||||
|
data: TileKey,
|
||||||
|
colors: &'static Palette,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Painter<'_> {
|
||||||
|
fn paint(&self, layers: &[Layer]) {
|
||||||
|
let c = self.colors;
|
||||||
|
let z = self.key.z;
|
||||||
|
set_rgb(self.cr, c.land, 1.0);
|
||||||
|
let _ = self.cr.paint();
|
||||||
|
|
||||||
|
let layer = |name: &str| layers.iter().find(|l| l.name == name);
|
||||||
|
if let Some(layer) = layer("landcover") {
|
||||||
|
self.fill(layer, |class| match class {
|
||||||
|
"wood" | "forest" => Some(c.wood),
|
||||||
|
"grass" | "wetland" => Some(c.grass),
|
||||||
|
"ice" | "glacier" => Some(c.ice),
|
||||||
|
"sand" | "beach" => Some(c.sand),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("landuse") {
|
||||||
|
self.fill(layer, |class| match class {
|
||||||
|
"residential" | "suburb" | "neighbourhood" => Some(c.residential),
|
||||||
|
"industrial" | "commercial" | "retail" | "railway" => Some(c.industrial),
|
||||||
|
"stadium" | "pitch" | "playground" | "cemetery" => Some(c.grass),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("park") {
|
||||||
|
self.fill(layer, |_| Some(c.grass));
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("water") {
|
||||||
|
self.fill(layer, |_| Some(c.water));
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("waterway") {
|
||||||
|
self.stroke(layer, |class| {
|
||||||
|
let (base, from) = match class {
|
||||||
|
"river" => (1.2, 8),
|
||||||
|
"canal" => (1.0, 12),
|
||||||
|
"stream" => (0.6, 13),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
(z >= from).then(|| (c.water, grow(base, z), false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if z >= 13
|
||||||
|
&& let Some(layer) = layer("building")
|
||||||
|
{
|
||||||
|
self.fill(layer, |_| Some(c.building));
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("transportation") {
|
||||||
|
self.roads(layer);
|
||||||
|
}
|
||||||
|
if let Some(layer) = layer("boundary") {
|
||||||
|
self.borders(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map tile units of the data tile onto this tile's logical px.
|
||||||
|
fn to_px(&self, layer: &Layer, (u, v): (f64, f64)) -> (f64, f64) {
|
||||||
|
let k = f64::from(1u32 << (self.key.z - self.data.z));
|
||||||
|
let ox = f64::from(self.key.x) - f64::from(self.data.x) * k;
|
||||||
|
let oy = f64::from(self.key.y) - f64::from(self.data.y) * k;
|
||||||
|
(
|
||||||
|
(u / layer.extent * k - ox) * TILE_EDGE,
|
||||||
|
(v / layer.extent * k - oy) * TILE_EDGE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trace(&self, layer: &Layer, part: &[(f64, f64)], close: bool) {
|
||||||
|
for (i, &point) in part.iter().enumerate() {
|
||||||
|
let (x, y) = self.to_px(layer, point);
|
||||||
|
if i == 0 {
|
||||||
|
self.cr.move_to(x, y);
|
||||||
|
} else {
|
||||||
|
self.cr.line_to(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if close {
|
||||||
|
self.cr.close_path();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill the layer's polygons in the colour `color` picks by class.
|
||||||
|
fn fill(&self, layer: &Layer, color: impl Fn(&str) -> Option<u32>) {
|
||||||
|
self.cr.set_fill_rule(cairo::FillRule::EvenOdd);
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Polygon {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = layer.text(feature, "class").unwrap_or("");
|
||||||
|
let Some(rgb) = color(class) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.cr.new_path();
|
||||||
|
for part in &feature.parts {
|
||||||
|
self.trace(layer, part, true);
|
||||||
|
}
|
||||||
|
set_rgb(self.cr, rgb, 1.0);
|
||||||
|
let _ = self.cr.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stroke the layer's lines as `style` says by class: colour, width in
|
||||||
|
/// px, and whether dashed.
|
||||||
|
fn stroke(&self, layer: &Layer, style: impl Fn(&str) -> Option<(u32, f64, bool)>) {
|
||||||
|
self.cr.set_line_cap(cairo::LineCap::Round);
|
||||||
|
self.cr.set_line_join(cairo::LineJoin::Round);
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Line {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = layer.text(feature, "class").unwrap_or("");
|
||||||
|
let Some((rgb, width, dashed)) = style(class) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.cr.new_path();
|
||||||
|
for part in &feature.parts {
|
||||||
|
self.trace(layer, part, false);
|
||||||
|
}
|
||||||
|
set_rgb(self.cr, rgb, 1.0);
|
||||||
|
self.cr.set_line_width(width);
|
||||||
|
let dash = [width * 2.0, width * 2.0];
|
||||||
|
self.cr.set_dash(if dashed { &dash } else { &[] }, 0.0);
|
||||||
|
let _ = self.cr.stroke();
|
||||||
|
}
|
||||||
|
self.cr.set_dash(&[], 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roads, minor first so the major ones cross over them, each on a
|
||||||
|
/// darker casing once they are wide enough for one.
|
||||||
|
fn roads(&self, layer: &Layer) {
|
||||||
|
let c = self.colors;
|
||||||
|
let z = self.key.z;
|
||||||
|
let road = |class: &str| -> Option<(u32, f64, u8)> {
|
||||||
|
let (color, width, rank) = match class {
|
||||||
|
"motorway" if z >= 6 => (c.motorway, grow(2.2, z), 4),
|
||||||
|
"trunk" if z >= 7 => (c.motorway, grow(2.2, z), 4),
|
||||||
|
"primary" if z >= 8 => (c.primary, grow(1.8, z), 3),
|
||||||
|
"secondary" if z >= 10 => (c.road, grow(1.4, z), 2),
|
||||||
|
"tertiary" if z >= 11 => (c.road, grow(1.4, z), 2),
|
||||||
|
"minor" | "busway" if z >= 12 => (c.road, grow(1.0, z), 1),
|
||||||
|
"service" if z >= 14 => (c.road, grow(0.6, z), 0),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((color, width, rank))
|
||||||
|
};
|
||||||
|
for rank in 0..=4u8 {
|
||||||
|
let pick = |class: &str| road(class).filter(|r| r.2 == rank);
|
||||||
|
if z >= 12 {
|
||||||
|
self.stroke(layer, |class| {
|
||||||
|
pick(class).map(|(_, width, _)| (c.casing, width + 1.5, false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.stroke(layer, |class| {
|
||||||
|
pick(class).map(|(color, width, _)| (color, width, false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.stroke(layer, |class| match class {
|
||||||
|
"rail" | "transit" if z >= 10 => Some((c.rail, grow(0.7, z).min(2.5), false)),
|
||||||
|
"path" | "track" if z >= 14 => Some((c.path, 0.8, true)),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn borders(&self, layer: &Layer) {
|
||||||
|
let c = self.colors;
|
||||||
|
let z = self.key.z;
|
||||||
|
self.cr.set_line_cap(cairo::LineCap::Round);
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Line || layer.number(feature, "maritime") == Some(1.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (color, width, dashed) = match layer.number(feature, "admin_level") {
|
||||||
|
Some(level) if level <= 2.0 => {
|
||||||
|
(c.border, (0.9 * 1.12f64.powi(i32::from(z))).min(2.5), false)
|
||||||
|
}
|
||||||
|
Some(level) if level <= 4.0 && z >= 5 => (c.state_border, 0.8, true),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let dashed = dashed || layer.number(feature, "disputed") == Some(1.0);
|
||||||
|
self.cr.new_path();
|
||||||
|
for part in &feature.parts {
|
||||||
|
self.trace(layer, part, false);
|
||||||
|
}
|
||||||
|
set_rgb(self.cr, color, 1.0);
|
||||||
|
self.cr.set_line_width(width);
|
||||||
|
self.cr
|
||||||
|
.set_dash(if dashed { &[3.0, 2.5] } else { &[] }, 0.0);
|
||||||
|
let _ = self.cr.stroke();
|
||||||
|
}
|
||||||
|
self.cr.set_dash(&[], 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line width that grows with the zoom, as the things it stands for get
|
||||||
|
/// bigger on screen.
|
||||||
|
fn grow(base: f64, z: u8) -> f64 {
|
||||||
|
(base * 1.4f64.powi(i32::from(z) - 10)).clamp(0.5, 24.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The names of towns, countries and seas on data tile `data`, in world
|
||||||
|
/// space. Names in the tile's buffer beyond its edge are left to the
|
||||||
|
/// neighbour they belong to.
|
||||||
|
fn gather_labels(layers: &[Layer], data: TileKey, language: &str) -> Vec<Label> {
|
||||||
|
let n = f64::from(1u32 << data.z);
|
||||||
|
let mut labels = Vec::new();
|
||||||
|
for layer in layers {
|
||||||
|
let water = match layer.name.as_str() {
|
||||||
|
"place" => false,
|
||||||
|
"water_name" => true,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
for feature in &layer.features {
|
||||||
|
if feature.kind != Kind::Point {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let class = if water {
|
||||||
|
LabelClass::Water
|
||||||
|
} else {
|
||||||
|
let Some(class) = layer
|
||||||
|
.text(feature, "class")
|
||||||
|
.and_then(LabelClass::from_place)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
class
|
||||||
|
};
|
||||||
|
let Some(text) = label_name(layer, feature, language) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let rank = layer.number(feature, "rank").unwrap_or(99.0);
|
||||||
|
for &(u, v) in feature.parts.iter().flatten() {
|
||||||
|
if !(0.0..layer.extent).contains(&u) || !(0.0..layer.extent).contains(&v) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
labels.push(Label {
|
||||||
|
x: (f64::from(data.x) + u / layer.extent) / n,
|
||||||
|
y: (f64::from(data.y) + v / layer.extent) / n,
|
||||||
|
text: text.clone(),
|
||||||
|
class,
|
||||||
|
rank,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feature's name in `language` where the map has it, else in Latin
|
||||||
|
/// letters, else as written locally.
|
||||||
|
fn label_name(layer: &Layer, feature: &mvt::Feature, language: &str) -> Option<String> {
|
||||||
|
[
|
||||||
|
format!("name:{language}"),
|
||||||
|
format!("name_{language}"),
|
||||||
|
"name:latin".to_owned(),
|
||||||
|
"name".to_owned(),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.find_map(|key| layer.text(feature, key).filter(|name| !name.is_empty()))
|
||||||
|
.map(|name| name.replace('\n', " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deep_tiles_draw_from_their_ancestor_at_the_data_zoom() {
|
||||||
|
let key = TileKey {
|
||||||
|
z: 16,
|
||||||
|
x: 34_336,
|
||||||
|
y: 22_437,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
key.data(),
|
||||||
|
TileKey {
|
||||||
|
z: 14,
|
||||||
|
x: 8_584,
|
||||||
|
y: 5_609
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let shallow = TileKey { z: 3, x: 4, y: 2 };
|
||||||
|
assert_eq!(shallow.data(), shallow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_build_comes_from_the_tile_template() {
|
||||||
|
let json = br#"{"tiles":["https://tiles.openfreemap.org/planet/20260913_164504_pt/{z}/{x}/{y}.pbf"]}"#;
|
||||||
|
let template = tile_template(json).unwrap();
|
||||||
|
assert_eq!(build_name(&template).as_deref(), Some("20260913_164504_pt"));
|
||||||
|
assert_eq!(build_name("https://example.org/../{z}/{x}/{y}.pbf"), None);
|
||||||
|
assert_eq!(
|
||||||
|
tile_template(br#"{"tiles":["http://plain.example/{z}"]}"#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_build_is_pruned_and_the_current_one_kept() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-map-prune-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join("old/1/0")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.join("new/1/0")).unwrap();
|
||||||
|
std::fs::write(dir.join("new/1/0/0.pbf"), b"tile").unwrap();
|
||||||
|
std::fs::write(dir.join("tiles.json"), b"{}").unwrap();
|
||||||
|
prune(&dir, "new");
|
||||||
|
assert!(!dir.join("old").exists());
|
||||||
|
assert!(dir.join("new/1/0/0.pbf").exists());
|
||||||
|
assert!(dir.join("tiles.json").exists());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roads_widen_as_the_map_zooms_in() {
|
||||||
|
assert!(grow(1.4, 14) > grow(1.4, 10));
|
||||||
|
assert_eq!(grow(2.2, 30), 24.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,15 @@ pub(crate) mod activity;
|
|||||||
pub(crate) mod albums;
|
pub(crate) mod albums;
|
||||||
pub(crate) mod browser;
|
pub(crate) mod browser;
|
||||||
pub(crate) mod devices;
|
pub(crate) mod devices;
|
||||||
|
pub(crate) mod duplicates;
|
||||||
pub(crate) mod locations;
|
pub(crate) mod locations;
|
||||||
pub(crate) mod login;
|
pub(crate) mod login;
|
||||||
|
pub(crate) mod map_tiles;
|
||||||
|
pub(crate) mod mvt;
|
||||||
pub(crate) mod photo_viewer;
|
pub(crate) mod photo_viewer;
|
||||||
pub(crate) mod photos;
|
pub(crate) mod photos;
|
||||||
|
pub(crate) mod places;
|
||||||
|
pub(crate) mod places_map;
|
||||||
pub(crate) mod shared;
|
pub(crate) mod shared;
|
||||||
pub(crate) mod shared_by_me;
|
pub(crate) mod shared_by_me;
|
||||||
pub(crate) mod status;
|
pub(crate) mod status;
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
//! A reader for Mapbox Vector Tiles, the format the online Places map's tiles
|
||||||
|
//! come in: protobuf, one message per tile, holding named layers of point,
|
||||||
|
//! line and polygon features with key/value tags.
|
||||||
|
//!
|
||||||
|
//! Only what drawing needs is read — layer names, tags and geometry — and a
|
||||||
|
//! tile that doesn't parse is `None` rather than an error: the map then shows
|
||||||
|
//! the outline underneath it. The format is at
|
||||||
|
//! <https://github.com/mapbox/vector-tile-spec>.
|
||||||
|
|
||||||
|
/// One layer of a tile, such as `water` or `transportation`.
|
||||||
|
pub(crate) struct Layer {
|
||||||
|
pub(crate) name: String,
|
||||||
|
/// The tile's width in geometry units; 4096 almost everywhere.
|
||||||
|
pub(crate) extent: f64,
|
||||||
|
keys: Vec<String>,
|
||||||
|
values: Vec<Value>,
|
||||||
|
pub(crate) features: Vec<Feature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(crate) enum Value {
|
||||||
|
Text(String),
|
||||||
|
Number(f64),
|
||||||
|
Bool(bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Kind {
|
||||||
|
Point,
|
||||||
|
Line,
|
||||||
|
Polygon,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Feature {
|
||||||
|
pub(crate) kind: Kind,
|
||||||
|
/// Pairs of indices into the layer's keys and values.
|
||||||
|
tags: Vec<(u32, u32)>,
|
||||||
|
/// Points, lines or polygon rings, in tile units. A point feature holds
|
||||||
|
/// one part with all its points.
|
||||||
|
pub(crate) parts: Vec<Vec<(f64, f64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layer {
|
||||||
|
/// The value `feature` carries for `key`.
|
||||||
|
pub(crate) fn tag(&self, feature: &Feature, key: &str) -> Option<&Value> {
|
||||||
|
feature.tags.iter().find_map(|&(k, v)| {
|
||||||
|
(self.keys.get(k as usize)? == key).then(|| self.values.get(v as usize))?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn text(&self, feature: &Feature, key: &str) -> Option<&str> {
|
||||||
|
match self.tag(feature, key)? {
|
||||||
|
Value::Text(text) => Some(text),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn number(&self, feature: &Feature, key: &str) -> Option<f64> {
|
||||||
|
match self.tag(feature, key)? {
|
||||||
|
Value::Number(n) => Some(*n),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every layer of the tile in `data`.
|
||||||
|
pub(crate) fn decode(data: &[u8]) -> Option<Vec<Layer>> {
|
||||||
|
let mut tile = Reader::new(data);
|
||||||
|
let mut layers = Vec::new();
|
||||||
|
while let Some((field, wire)) = tile.field()? {
|
||||||
|
match field {
|
||||||
|
3 => layers.push(decode_layer(tile.bytes()?)?),
|
||||||
|
_ => tile.skip(wire)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(layers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_layer(data: &[u8]) -> Option<Layer> {
|
||||||
|
let mut layer = Layer {
|
||||||
|
name: String::new(),
|
||||||
|
extent: 4096.0,
|
||||||
|
keys: Vec::new(),
|
||||||
|
values: Vec::new(),
|
||||||
|
features: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
while let Some((field, wire)) = reader.field()? {
|
||||||
|
match field {
|
||||||
|
1 => layer.name = reader.text()?,
|
||||||
|
2 => {
|
||||||
|
if let Some(feature) = decode_feature(reader.bytes()?) {
|
||||||
|
layer.features.push(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => layer.keys.push(reader.text()?),
|
||||||
|
4 => layer.values.push(decode_value(reader.bytes()?)?),
|
||||||
|
5 => layer.extent = reader.varint()? as f64,
|
||||||
|
_ => reader.skip(wire)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_value(data: &[u8]) -> Option<Value> {
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
let mut value = Value::Bool(false);
|
||||||
|
while let Some((field, wire)) = reader.field()? {
|
||||||
|
value = match field {
|
||||||
|
1 => Value::Text(reader.text()?),
|
||||||
|
2 => Value::Number(f64::from(f32::from_bits(reader.fixed32()?))),
|
||||||
|
3 => Value::Number(f64::from_bits(reader.fixed64()?)),
|
||||||
|
4 | 5 => Value::Number(reader.varint()? as i64 as f64),
|
||||||
|
6 => Value::Number(zigzag(reader.varint()?) as f64),
|
||||||
|
7 => Value::Bool(reader.varint()? != 0),
|
||||||
|
_ => {
|
||||||
|
reader.skip(wire)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feature, or `None` for one this map can't draw (unknown geometry type,
|
||||||
|
/// or broken).
|
||||||
|
fn decode_feature(data: &[u8]) -> Option<Feature> {
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
let mut kind = None;
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
let mut geometry = Vec::new();
|
||||||
|
while let Some((field, wire)) = reader.field()? {
|
||||||
|
match field {
|
||||||
|
2 => {
|
||||||
|
let packed = reader.packed()?;
|
||||||
|
tags = packed
|
||||||
|
.as_chunks::<2>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|&[k, v]| (k as u32, v as u32))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
kind = match reader.varint()? {
|
||||||
|
1 => Some(Kind::Point),
|
||||||
|
2 => Some(Kind::Line),
|
||||||
|
3 => Some(Kind::Polygon),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4 => geometry = reader.packed()?,
|
||||||
|
_ => reader.skip(wire)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kind = kind?;
|
||||||
|
Some(Feature {
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
parts: decode_geometry(kind, &geometry)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn the command stream of a feature's geometry into its parts. Positions
|
||||||
|
/// are deltas from the previous one, carried across parts.
|
||||||
|
fn decode_geometry(kind: Kind, commands: &[u64]) -> Option<Vec<Vec<(f64, f64)>>> {
|
||||||
|
let mut parts: Vec<Vec<(f64, f64)>> = Vec::new();
|
||||||
|
let (mut x, mut y) = (0i64, 0i64);
|
||||||
|
let mut i = 0;
|
||||||
|
while i < commands.len() {
|
||||||
|
let command = commands[i] & 7;
|
||||||
|
let count = (commands[i] >> 3) as usize;
|
||||||
|
i += 1;
|
||||||
|
match command {
|
||||||
|
// MoveTo starts a part — except for points, which all share one.
|
||||||
|
1 | 2 => {
|
||||||
|
if i + 2 * count > commands.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for n in 0..count {
|
||||||
|
x += zigzag(commands[i + 2 * n]);
|
||||||
|
y += zigzag(commands[i + 2 * n + 1]);
|
||||||
|
let starts = command == 1 && (kind != Kind::Point || parts.is_empty());
|
||||||
|
if starts {
|
||||||
|
parts.push(Vec::new());
|
||||||
|
}
|
||||||
|
parts.last_mut()?.push((x as f64, y as f64));
|
||||||
|
}
|
||||||
|
i += 2 * count;
|
||||||
|
}
|
||||||
|
// ClosePath: the ring's end joins its start, which drawing does.
|
||||||
|
7 => {}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zigzag(n: u64) -> i64 {
|
||||||
|
((n >> 1) as i64) ^ -((n & 1) as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The protobuf wire format, as far as vector tiles use it.
|
||||||
|
struct Reader<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
at: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Reader<'a> {
|
||||||
|
fn new(data: &'a [u8]) -> Self {
|
||||||
|
Self { data, at: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next field's number and wire type; `Some(None)` at the end, `None`
|
||||||
|
/// on a broken message.
|
||||||
|
fn field(&mut self) -> Option<Option<(u64, u8)>> {
|
||||||
|
if self.at >= self.data.len() {
|
||||||
|
return Some(None);
|
||||||
|
}
|
||||||
|
let key = self.varint()?;
|
||||||
|
Some(Some((key >> 3, (key & 7) as u8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn varint(&mut self) -> Option<u64> {
|
||||||
|
let mut value = 0u64;
|
||||||
|
for shift in (0..64).step_by(7) {
|
||||||
|
let byte = *self.data.get(self.at)?;
|
||||||
|
self.at += 1;
|
||||||
|
value |= u64::from(byte & 0x7f) << shift;
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
|
||||||
|
let end = self.at.checked_add(n)?;
|
||||||
|
let bytes = self.data.get(self.at..end)?;
|
||||||
|
self.at = end;
|
||||||
|
Some(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(&mut self) -> Option<&'a [u8]> {
|
||||||
|
let len = usize::try_from(self.varint()?).ok()?;
|
||||||
|
self.take(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(&mut self) -> Option<String> {
|
||||||
|
Some(String::from_utf8_lossy(self.bytes()?).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed32(&mut self) -> Option<u32> {
|
||||||
|
Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed64(&mut self) -> Option<u64> {
|
||||||
|
Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packed(&mut self) -> Option<Vec<u64>> {
|
||||||
|
let mut inner = Reader::new(self.bytes()?);
|
||||||
|
let mut values = Vec::new();
|
||||||
|
while inner.at < inner.data.len() {
|
||||||
|
values.push(inner.varint()?);
|
||||||
|
}
|
||||||
|
Some(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip(&mut self, wire: u8) -> Option<()> {
|
||||||
|
match wire {
|
||||||
|
0 => {
|
||||||
|
self.varint()?;
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
self.take(8)?;
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
self.bytes()?;
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
self.take(4)?;
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn varint(mut n: u64, out: &mut Vec<u8>) {
|
||||||
|
while n >= 0x80 {
|
||||||
|
out.push((n as u8) | 0x80);
|
||||||
|
n >>= 7;
|
||||||
|
}
|
||||||
|
out.push(n as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(number: u64, bytes: &[u8], out: &mut Vec<u8>) {
|
||||||
|
varint(number << 3 | 2, out);
|
||||||
|
varint(bytes.len() as u64, out);
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packed(values: &[u64]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for &v in values {
|
||||||
|
varint(v, &mut out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zz(n: i64) -> u64 {
|
||||||
|
((n << 1) ^ (n >> 63)) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tile with one `water` layer holding one square tagged class=lake.
|
||||||
|
fn lake_tile() -> Vec<u8> {
|
||||||
|
let mut feature = Vec::new();
|
||||||
|
field(2, &packed(&[0, 0]), &mut feature);
|
||||||
|
varint(3 << 3, &mut feature);
|
||||||
|
varint(3, &mut feature);
|
||||||
|
let square = [
|
||||||
|
1 | 1 << 3,
|
||||||
|
zz(10),
|
||||||
|
zz(10),
|
||||||
|
2 | 3 << 3,
|
||||||
|
zz(20),
|
||||||
|
zz(0),
|
||||||
|
zz(0),
|
||||||
|
zz(20),
|
||||||
|
zz(-20),
|
||||||
|
zz(0),
|
||||||
|
7 | 1 << 3,
|
||||||
|
];
|
||||||
|
field(4, &packed(&square), &mut feature);
|
||||||
|
|
||||||
|
let mut value = Vec::new();
|
||||||
|
field(1, b"lake", &mut value);
|
||||||
|
|
||||||
|
let mut layer = Vec::new();
|
||||||
|
field(1, b"water", &mut layer);
|
||||||
|
field(2, &feature, &mut layer);
|
||||||
|
field(3, b"class", &mut layer);
|
||||||
|
field(4, &value, &mut layer);
|
||||||
|
varint(5 << 3, &mut layer);
|
||||||
|
varint(4096, &mut layer);
|
||||||
|
|
||||||
|
let mut tile = Vec::new();
|
||||||
|
field(3, &layer, &mut tile);
|
||||||
|
tile
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_polygon_decodes_with_its_tags() {
|
||||||
|
let layers = decode(&lake_tile()).unwrap();
|
||||||
|
let [water] = &layers[..] else {
|
||||||
|
panic!("one layer expected");
|
||||||
|
};
|
||||||
|
assert_eq!(water.name, "water");
|
||||||
|
assert_eq!(water.extent, 4096.0);
|
||||||
|
let feature = &water.features[0];
|
||||||
|
assert_eq!(feature.kind, Kind::Polygon);
|
||||||
|
assert_eq!(water.text(feature, "class"), Some("lake"));
|
||||||
|
assert_eq!(water.text(feature, "name"), None);
|
||||||
|
assert_eq!(
|
||||||
|
feature.parts,
|
||||||
|
vec![vec![(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cut_off_tile_is_rejected() {
|
||||||
|
let tile = lake_tile();
|
||||||
|
assert!(decode(&tile[..tile.len() - 3]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn points_of_one_feature_share_a_part() {
|
||||||
|
let commands = [1 | 2 << 3, zz(5), zz(5), zz(1), zz(-1)];
|
||||||
|
let parts = decode_geometry(Kind::Point, &commands).unwrap();
|
||||||
|
assert_eq!(parts, vec![vec![(5.0, 5.0), (6.0, 4.0)]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ const ZOOM_STEP: f64 = 1.25;
|
|||||||
/// The furthest the lightbox zooms in, as a multiple of the image's own pixels.
|
/// The furthest the lightbox zooms in, as a multiple of the image's own pixels.
|
||||||
const MAX_ZOOM: f64 = 8.0;
|
const MAX_ZOOM: f64 = 8.0;
|
||||||
|
|
||||||
|
/// How long a slideshow shows each photo. A video plays to its end instead.
|
||||||
|
const SLIDESHOW_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How often a slideshow checks whether the video on screen has finished.
|
||||||
|
const SLIDESHOW_VIDEO_POLL: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
/// How close to the end of the loaded photos stepping forward starts paging the
|
/// How close to the end of the loaded photos stepping forward starts paging the
|
||||||
/// next batch in, so reaching the end rarely means waiting for it.
|
/// next batch in, so reaching the end rarely means waiting for it.
|
||||||
const PAGE_AHEAD: u32 = 10;
|
const PAGE_AHEAD: u32 = 10;
|
||||||
@@ -75,6 +81,12 @@ pub(crate) struct Viewer {
|
|||||||
pub(crate) chrome: Vec<gtk4::Revealer>,
|
pub(crate) chrome: Vec<gtk4::Revealer>,
|
||||||
pub(crate) chrome_hover: Cell<bool>,
|
pub(crate) chrome_hover: Cell<bool>,
|
||||||
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
||||||
|
/// The slideshow toggle; the timer that moves it on; and whether it put
|
||||||
|
/// the window in fullscreen, so stopping it only leaves a fullscreen it
|
||||||
|
/// entered itself.
|
||||||
|
pub(crate) slideshow: gtk4::ToggleButton,
|
||||||
|
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
|
||||||
|
pub(crate) slide_fullscreened: Cell<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
||||||
@@ -220,6 +232,8 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
stop_video(viewer);
|
stop_video(viewer);
|
||||||
viewer.media.set_visible_child_name("photo");
|
viewer.media.set_visible_child_name("photo");
|
||||||
reset_zoom(viewer);
|
reset_zoom(viewer);
|
||||||
|
// A slideshow waits for this photo to land before it starts counting.
|
||||||
|
disarm_slide(viewer);
|
||||||
|
|
||||||
// Looked up before the group is refetched, which empties it.
|
// Looked up before the group is refetched, which empties it.
|
||||||
let item = viewer_item(ui, viewer, &uid);
|
let item = viewer_item(ui, viewer, &uid);
|
||||||
@@ -244,6 +258,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
Request::OpenPhoto { uid: uid.clone() },
|
Request::OpenPhoto { uid: uid.clone() },
|
||||||
);
|
);
|
||||||
let viewer = viewer.clone();
|
let viewer = viewer.clone();
|
||||||
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
// The user may have moved on while this was in flight; that photo's own
|
// The user may have moved on while this was in flight; that photo's own
|
||||||
@@ -254,6 +269,9 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
viewer.spinner.stop();
|
viewer.spinner.stop();
|
||||||
viewer.spinner.set_visible(false);
|
viewer.spinner.set_visible(false);
|
||||||
viewer.loading.set(false);
|
viewer.loading.set(false);
|
||||||
|
// Armed whether or not the photo rendered: a slideshow steps past a
|
||||||
|
// photo it could not show rather than stalling on it.
|
||||||
|
arm_slide(&ui, &viewer);
|
||||||
|
|
||||||
let fail = |message: &str| {
|
let fail = |message: &str| {
|
||||||
viewer.status.set_label(message);
|
viewer.status.set_label(message);
|
||||||
@@ -295,6 +313,92 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
|
||||||
|
/// screen counted down once it has loaded.
|
||||||
|
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
if !viewer.window.is_fullscreen() {
|
||||||
|
viewer.window.fullscreen();
|
||||||
|
viewer.slide_fullscreened.set(true);
|
||||||
|
}
|
||||||
|
viewer
|
||||||
|
.slideshow
|
||||||
|
.set_icon_name("media-playback-pause-symbolic");
|
||||||
|
hide_chrome(viewer);
|
||||||
|
if !viewer.loading.get() {
|
||||||
|
arm_slide(ui, viewer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
|
||||||
|
fn stop_slideshow(viewer: &Rc<Viewer>) {
|
||||||
|
disarm_slide(viewer);
|
||||||
|
if viewer.slide_fullscreened.replace(false) {
|
||||||
|
viewer.window.unfullscreen();
|
||||||
|
}
|
||||||
|
viewer
|
||||||
|
.slideshow
|
||||||
|
.set_icon_name("media-playback-start-symbolic");
|
||||||
|
if viewer.slideshow.is_active() {
|
||||||
|
viewer.slideshow.set_active(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a running slideshow on from the photo on screen: after
|
||||||
|
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
|
||||||
|
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
disarm_slide(viewer);
|
||||||
|
if !viewer.slideshow.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
|
||||||
|
let (ui, weak) = (ui.clone(), Rc::downgrade(viewer));
|
||||||
|
let source = if playing_video {
|
||||||
|
glib::timeout_add_local(SLIDESHOW_VIDEO_POLL, move || {
|
||||||
|
let Some(viewer) = weak.upgrade() else {
|
||||||
|
return glib::ControlFlow::Break;
|
||||||
|
};
|
||||||
|
let ended = viewer
|
||||||
|
.video
|
||||||
|
.media_stream()
|
||||||
|
.is_some_and(|stream| stream.is_ended());
|
||||||
|
if !ended {
|
||||||
|
return glib::ControlFlow::Continue;
|
||||||
|
}
|
||||||
|
// This timer is finishing, so its id must not be removed again.
|
||||||
|
viewer.slide_source.borrow_mut().take();
|
||||||
|
next_slide(&ui, &viewer);
|
||||||
|
glib::ControlFlow::Break
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
glib::timeout_add_local_once(SLIDESHOW_INTERVAL, move || {
|
||||||
|
let Some(viewer) = weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
viewer.slide_source.borrow_mut().take();
|
||||||
|
next_slide(&ui, &viewer);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
*viewer.slide_source.borrow_mut() = Some(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disarm_slide(viewer: &Rc<Viewer>) {
|
||||||
|
if let Some(source) = viewer.slide_source.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step the slideshow forward, or end it at the last photo there is.
|
||||||
|
fn next_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
|
||||||
|
let model = &ui.gallery.model;
|
||||||
|
let last = find_photo_index(model, &viewer.uid.borrow())
|
||||||
|
.is_some_and(|index| index + 1 >= model.n_items());
|
||||||
|
if last && !ui.gallery.has_more.get() {
|
||||||
|
stop_slideshow(viewer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate_photo(ui, viewer, 1);
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop whatever the player was playing, so stepping off a video (or closing
|
/// Stop whatever the player was playing, so stepping off a video (or closing
|
||||||
/// the lightbox) doesn't leave its sound running.
|
/// the lightbox) doesn't leave its sound running.
|
||||||
fn stop_video(viewer: &Rc<Viewer>) {
|
fn stop_video(viewer: &Rc<Viewer>) {
|
||||||
@@ -813,7 +917,13 @@ fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
|
|||||||
if let Some(album) = ui.gallery.album.borrow().as_ref() {
|
if let Some(album) = ui.gallery.album.borrow().as_ref() {
|
||||||
return Some(album.photo_count);
|
return Some(album.photo_count);
|
||||||
}
|
}
|
||||||
if ui.gallery.range.get().is_some() || ui.gallery.favorites.get() {
|
if let Some(place) = ui.gallery.place.borrow().as_ref() {
|
||||||
|
return Some(place.photo_count);
|
||||||
|
}
|
||||||
|
if ui.gallery.range.get().is_some()
|
||||||
|
|| ui.gallery.favorites.get()
|
||||||
|
|| ui.gallery.not_in_album.get()
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (photos, videos, raw) = ui.gallery.counts.get()?;
|
let (photos, videos, raw) = ui.gallery.counts.get()?;
|
||||||
@@ -985,6 +1095,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
favorite_btn.add_css_class("flat");
|
favorite_btn.add_css_class("flat");
|
||||||
favorite_btn.add_css_class("viewer-action-btn");
|
favorite_btn.add_css_class("viewer-action-btn");
|
||||||
|
|
||||||
|
let slideshow_btn = gtk4::ToggleButton::builder()
|
||||||
|
.icon_name("media-playback-start-symbolic")
|
||||||
|
.tooltip_text(gettext("Slideshow (F5)"))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
slideshow_btn.add_css_class("flat");
|
||||||
|
slideshow_btn.add_css_class("viewer-action-btn");
|
||||||
|
|
||||||
// Only shown for a shot stored as more than one file; `load_photo_group`
|
// Only shown for a shot stored as more than one file; `load_photo_group`
|
||||||
// decides that per photo.
|
// decides that per photo.
|
||||||
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
|
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
|
||||||
@@ -1002,6 +1120,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
top_bar.append(&group_btn);
|
top_bar.append(&group_btn);
|
||||||
top_bar.append(&favorite_btn);
|
top_bar.append(&favorite_btn);
|
||||||
top_bar.append(&info_toggle);
|
top_bar.append(&info_toggle);
|
||||||
|
top_bar.append(&slideshow_btn);
|
||||||
top_bar.append(&download_btn);
|
top_bar.append(&download_btn);
|
||||||
top_bar.append(&delete_btn);
|
top_bar.append(&delete_btn);
|
||||||
top_bar.append(&open_ext_btn);
|
top_bar.append(&open_ext_btn);
|
||||||
@@ -1128,6 +1247,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
|
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
|
||||||
chrome_hover: Cell::new(false),
|
chrome_hover: Cell::new(false),
|
||||||
chrome_source: RefCell::new(None),
|
chrome_source: RefCell::new(None),
|
||||||
|
slideshow: slideshow_btn.clone(),
|
||||||
|
slide_source: RefCell::new(None),
|
||||||
|
slide_fullscreened: Cell::new(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
||||||
@@ -1241,6 +1363,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
let viewer_closing = viewer.clone();
|
let viewer_closing = viewer.clone();
|
||||||
window.connect_close_request(move |_| {
|
window.connect_close_request(move |_| {
|
||||||
stop_video(&viewer_closing);
|
stop_video(&viewer_closing);
|
||||||
|
disarm_slide(&viewer_closing);
|
||||||
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() {
|
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() {
|
||||||
source.remove();
|
source.remove();
|
||||||
}
|
}
|
||||||
@@ -1254,15 +1377,27 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
.set_reveal_child(toggle.is_active());
|
.set_reveal_child(toggle.is_active());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let ui_slides = ui.clone();
|
||||||
|
let viewer_slides = viewer.clone();
|
||||||
|
slideshow_btn.connect_toggled(move |toggle| {
|
||||||
|
if toggle.is_active() {
|
||||||
|
start_slideshow(&ui_slides, &viewer_slides);
|
||||||
|
} else {
|
||||||
|
stop_slideshow(&viewer_slides);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let toggle_off = info_toggle.clone();
|
let toggle_off = info_toggle.clone();
|
||||||
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||||
|
|
||||||
|
let ui_map = ui.clone();
|
||||||
let viewer_map = viewer.clone();
|
let viewer_map = viewer.clone();
|
||||||
|
let w_map = window.clone();
|
||||||
info_map.connect_clicked(move |_| {
|
info_map.connect_clicked(move |_| {
|
||||||
if let Some((lat, lon)) = *viewer_map.coords.borrow() {
|
let coords = *viewer_map.coords.borrow();
|
||||||
open_path(&format!(
|
if let Some((lat, lon)) = coords {
|
||||||
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}"
|
w_map.close();
|
||||||
));
|
show_on_map(&ui_map, lat, lon);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1317,7 +1452,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
delete_btn.connect_clicked(move |_| {
|
delete_btn.connect_clicked(move |_| {
|
||||||
let uid = viewer_delete.uid.borrow().clone();
|
let uid = viewer_delete.uid.borrow().clone();
|
||||||
w_delete.close();
|
w_delete.close();
|
||||||
trash_photos(&ui_delete, vec![uid]);
|
trash_photos(&ui_delete, vec![uid], false);
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_group = ui.clone();
|
let ui_group = ui.clone();
|
||||||
@@ -1423,6 +1558,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
||||||
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
||||||
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
||||||
|
Some("F5") => viewer_key
|
||||||
|
.slideshow
|
||||||
|
.set_active(!viewer_key.slideshow.is_active()),
|
||||||
|
// Escape ends a slideshow before it closes anything.
|
||||||
|
Some("Escape") if viewer_key.slideshow.is_active() => {
|
||||||
|
viewer_key.slideshow.set_active(false)
|
||||||
|
}
|
||||||
Some("i") => viewer_key
|
Some("i") => viewer_key
|
||||||
.info_toggle
|
.info_toggle
|
||||||
.set_active(!viewer_key.info_toggle.is_active()),
|
.set_active(!viewer_key.info_toggle.is_active()),
|
||||||
@@ -1436,7 +1578,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
Some("Delete" | "KP_Delete") => {
|
Some("Delete" | "KP_Delete") => {
|
||||||
let uid = viewer_key.uid.borrow().clone();
|
let uid = viewer_key.uid.borrow().clone();
|
||||||
w_key.close();
|
w_key.close();
|
||||||
trash_photos(&ui_key, vec![uid]);
|
trash_photos(&ui_key, vec![uid], false);
|
||||||
}
|
}
|
||||||
Some("Escape" | "q") => w_key.close(),
|
Some("Escape" | "q") => w_key.close(),
|
||||||
Some("w") if ctrl => w_key.close(),
|
Some("w") if ctrl => w_key.close(),
|
||||||
|
|||||||
+1098
-268
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,514 @@
|
|||||||
|
//! The Places view of the Photos page: a grid of the towns the photos were
|
||||||
|
//! taken in, and the place that opens when one is clicked.
|
||||||
|
//!
|
||||||
|
//! An open place works like an open album: it is parked in
|
||||||
|
//! [`GalleryState::place`], and [`load_gallery`] pages
|
||||||
|
//! [`Request::PlacePhotos`] instead of the timeline.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// Edge length in px of a place's cover, the same as an album's.
|
||||||
|
const COVER_EDGE: i32 = 200;
|
||||||
|
|
||||||
|
/// Edge length in px of a photo in the list of photos taken on one spot.
|
||||||
|
const SPOT_EDGE: i32 = 96;
|
||||||
|
|
||||||
|
/// How often the listing is asked for again while photo locations are read.
|
||||||
|
const MAPPING_POLL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Show the Places grid and (re)load it. Called when the Places toggle goes on.
|
||||||
|
pub(crate) fn show_places(ui: &Rc<Ui>) {
|
||||||
|
show_place_grid(ui);
|
||||||
|
load_places(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to the place grid as it was last filled, keeping its scroll
|
||||||
|
/// position. The timeline's filters do not apply to places, so they go.
|
||||||
|
pub(crate) fn show_place_grid(ui: &Rc<Ui>) {
|
||||||
|
close_place(ui);
|
||||||
|
close_album(ui);
|
||||||
|
ui.gallery.filters.set_visible(false);
|
||||||
|
ui.gallery.content.set_visible_child_name("places");
|
||||||
|
ui.gallery
|
||||||
|
.title
|
||||||
|
.set_subtitle(&places_subtitle(ui.gallery.place_count.get()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the daemon for the place listing and rebuild the grid.
|
||||||
|
pub(crate) fn load_places(ui: &Rc<Ui>) {
|
||||||
|
if ui.gallery.places_loading.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.gallery.places_loading.set(true);
|
||||||
|
// A grid already on screen stays there while it refreshes.
|
||||||
|
if ui.gallery.places_stack.visible_child_name().as_deref() != Some("grid") {
|
||||||
|
places_status(
|
||||||
|
ui,
|
||||||
|
"mark-location-symbolic",
|
||||||
|
&gettext("Loading places…"),
|
||||||
|
&gettext("Finding where your photos were taken."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoPlaces);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.gallery.places_loading.set(false);
|
||||||
|
let mapping = match &result {
|
||||||
|
Ok(Ok(Response::Places { mapping, .. })) => *mapping,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
sync_mapping(&ui, mapping);
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Places { items, .. })) if items.is_empty() && mapping.is_some() => {
|
||||||
|
ui.gallery.place_count.set(0);
|
||||||
|
places_status(
|
||||||
|
&ui,
|
||||||
|
"mark-location-symbolic",
|
||||||
|
&gettext("Mapping your photos…"),
|
||||||
|
&gettext("Places show up here once the locations of your photos are read."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Places { items, .. })) if items.is_empty() => {
|
||||||
|
ui.gallery.place_count.set(0);
|
||||||
|
if ui.gallery.place.borrow().is_none() {
|
||||||
|
ui.gallery.title.set_subtitle("");
|
||||||
|
}
|
||||||
|
places_status(
|
||||||
|
&ui,
|
||||||
|
"mark-location-symbolic",
|
||||||
|
&gettext("No places"),
|
||||||
|
&gettext(
|
||||||
|
"Photos that carry the location they were taken at show up here, grouped by town.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Places { items, .. })) => {
|
||||||
|
fill_places(&ui, &items);
|
||||||
|
ui.gallery.places_map.set_places(&items);
|
||||||
|
show_places_layout(&ui);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, &gettext("Couldn't load places"), &message, kind);
|
||||||
|
places_status(
|
||||||
|
&ui,
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
&gettext("Couldn't load places"),
|
||||||
|
&message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => places_status(
|
||||||
|
&ui,
|
||||||
|
"network-offline-symbolic",
|
||||||
|
&gettext("Not connected"),
|
||||||
|
&gettext("The Proton Drive mount service didn't respond."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show how far the daemon has got reading photo locations, and ask again in a
|
||||||
|
/// moment while it is still reading — the new places land once it is done.
|
||||||
|
fn sync_mapping(ui: &Rc<Ui>, mapping: Option<MappingProgress>) {
|
||||||
|
if let Some(source) = ui.gallery.places_poll.take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
ui.gallery.places_mapping.set_visible(mapping.is_some());
|
||||||
|
let Some(MappingProgress { done, total }) = mapping else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
ui.gallery.places_mapping_label.set_label(&gettext_f(
|
||||||
|
// Translators: {done} and {total} are numbers of photos.
|
||||||
|
"Mapping photos: {done} of {total}",
|
||||||
|
&[("done", &thousands(done)), ("total", &thousands(total))],
|
||||||
|
));
|
||||||
|
ui.gallery.places_mapping_bar.set_fraction(if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
done as f64 / total as f64
|
||||||
|
});
|
||||||
|
let ui_poll = ui.clone();
|
||||||
|
let source = glib::timeout_add_local_once(MAPPING_POLL, move || {
|
||||||
|
ui_poll.gallery.places_poll.take();
|
||||||
|
let on_places = ui_poll.gallery.content.visible_child_name().as_deref() == Some("places");
|
||||||
|
if on_places && ui_poll.gallery.place.borrow().is_none() {
|
||||||
|
load_places(&ui_poll);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.gallery.places_poll.replace(Some(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the grid's cards with `places`, the one with the most photos first.
|
||||||
|
fn fill_places(ui: &Rc<Ui>, places: &[PlaceInfo]) {
|
||||||
|
let scroll = ui
|
||||||
|
.gallery
|
||||||
|
.places
|
||||||
|
.ancestor(gtk4::ScrolledWindow::static_type())
|
||||||
|
.and_downcast::<gtk4::ScrolledWindow>();
|
||||||
|
let position = scroll.as_ref().map(|s| s.vadjustment().value());
|
||||||
|
while let Some(child) = ui.gallery.places.first_child() {
|
||||||
|
ui.gallery.places.remove(&child);
|
||||||
|
}
|
||||||
|
ui.gallery.place_count.set(places.len());
|
||||||
|
if ui.gallery.place.borrow().is_none() {
|
||||||
|
ui.gallery
|
||||||
|
.title
|
||||||
|
.set_subtitle(&places_subtitle(places.len()));
|
||||||
|
}
|
||||||
|
for place in places {
|
||||||
|
ui.gallery.places.append(&place_card(ui, place));
|
||||||
|
}
|
||||||
|
schedule_thumbs(ui);
|
||||||
|
if let (Some(scroll), Some(position)) = (scroll, position) {
|
||||||
|
glib::idle_add_local_once(move || scroll.vadjustment().set_value(position));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn places_subtitle(count: usize) -> String {
|
||||||
|
ngettext_f("{n} place", "{n} places", count as u64, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Germany · 12 photos": where the town is, and how much was taken there.
|
||||||
|
pub(crate) fn place_subtitle(place: &PlaceInfo) -> String {
|
||||||
|
ngettext_f(
|
||||||
|
// Translators: {country} is a country name, {n} a number of photos.
|
||||||
|
"{country} · {n} photo",
|
||||||
|
"{country} · {n} photos",
|
||||||
|
place.photo_count as u64,
|
||||||
|
&[("country", &country_name(&place.country))],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One place as a clickable card: its newest photo, the town, and the country
|
||||||
|
/// with the number of photos taken there.
|
||||||
|
fn place_card(ui: &Rc<Ui>, place: &PlaceInfo) -> gtk4::Button {
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.can_shrink(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
picture.add_css_class("photo-thumb");
|
||||||
|
|
||||||
|
let placeholder = gtk4::Image::builder()
|
||||||
|
.icon_name("mark-location-symbolic")
|
||||||
|
.pixel_size(32)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
placeholder.add_css_class("photo-placeholder");
|
||||||
|
|
||||||
|
let cover = gtk4::Overlay::new();
|
||||||
|
cover.set_child(Some(&placeholder));
|
||||||
|
cover.add_overlay(&picture);
|
||||||
|
cover.set_size_request(COVER_EDGE, COVER_EDGE);
|
||||||
|
cover.set_overflow(gtk4::Overflow::Hidden);
|
||||||
|
cover.add_css_class("album-cover");
|
||||||
|
|
||||||
|
let name = gtk4::Label::builder()
|
||||||
|
.label(&place.name)
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.xalign(0.0)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.build();
|
||||||
|
name.add_css_class("heading");
|
||||||
|
|
||||||
|
let subtitle = place_subtitle(place);
|
||||||
|
let count = gtk4::Label::builder()
|
||||||
|
.label(&subtitle)
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.xalign(0.0)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.build();
|
||||||
|
count.add_css_class("dim-label");
|
||||||
|
count.add_css_class("caption");
|
||||||
|
|
||||||
|
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||||
|
card.append(&cover);
|
||||||
|
card.append(&name);
|
||||||
|
card.append(&count);
|
||||||
|
|
||||||
|
let button = gtk4::Button::builder().child(&card).build();
|
||||||
|
button.add_css_class("flat");
|
||||||
|
button.add_css_class("album-card");
|
||||||
|
button.set_tooltip_text(Some(&format!("{}\n{subtitle}", place.name)));
|
||||||
|
|
||||||
|
want_cover(ui, place.cover.uid.clone(), &picture);
|
||||||
|
// The map marks the town with the same photo.
|
||||||
|
cover_map_marker(ui, &place.cover.uid, &picture);
|
||||||
|
|
||||||
|
let ui_open = ui.clone();
|
||||||
|
let place = place.clone();
|
||||||
|
button.connect_clicked(move |_| open_place(&ui_open, place.clone()));
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the map the photo `picture` shows once it is there.
|
||||||
|
fn cover_map_marker(ui: &Rc<Ui>, uid: &str, picture: >k4::Picture) {
|
||||||
|
let map = Rc::downgrade(&ui.gallery.places_map);
|
||||||
|
let set = move |picture: >k4::Picture, uid: &str| {
|
||||||
|
let texture = picture.paintable().and_downcast::<gtk4::gdk::Texture>();
|
||||||
|
if let (Some(map), Some(texture)) = (map.upgrade(), texture) {
|
||||||
|
map.set_cover(uid, &texture);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set(picture, uid);
|
||||||
|
let uid = uid.to_owned();
|
||||||
|
picture.connect_paintable_notify(move |picture| set(picture, &uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the map the thumbnail of a photo it marks on its own, through the
|
||||||
|
/// gallery's thumbnail pipeline.
|
||||||
|
fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
|
||||||
|
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&photo.uid) {
|
||||||
|
ui.gallery.places_map.set_cover(&photo.uid, texture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A town's card may be waiting for the same photo: share its picture
|
||||||
|
// rather than take its place in the queue.
|
||||||
|
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||||
|
let picture = waiting.unwrap_or_else(|| {
|
||||||
|
let picture = gtk4::Picture::new();
|
||||||
|
want_thumb(ui, photo, &picture);
|
||||||
|
picture
|
||||||
|
});
|
||||||
|
cover_map_marker(ui, &photo.uid, &picture);
|
||||||
|
schedule_thumbs(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the photos taken on one spot of the map in a popover at `(x, y)`;
|
||||||
|
/// clicking one opens it in its town.
|
||||||
|
fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f64, f64)) {
|
||||||
|
let Some((place, _)) = photos.first() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let popover = gtk4::Popover::new();
|
||||||
|
popover.set_parent(ui.gallery.places_map.widget());
|
||||||
|
popover.set_pointing_to(Some(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
|
||||||
|
|
||||||
|
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||||
|
content.set_margin_top(6);
|
||||||
|
content.set_margin_bottom(6);
|
||||||
|
content.set_margin_start(6);
|
||||||
|
content.set_margin_end(6);
|
||||||
|
let title = gtk4::Label::builder()
|
||||||
|
.label(&place.name)
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
title.add_css_class("heading");
|
||||||
|
let count = gtk4::Label::builder()
|
||||||
|
.label(ngettext_f(
|
||||||
|
"{n} photo",
|
||||||
|
"{n} photos",
|
||||||
|
photos.len() as u64,
|
||||||
|
&[],
|
||||||
|
))
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
count.add_css_class("dim-label");
|
||||||
|
count.add_css_class("caption");
|
||||||
|
content.append(&title);
|
||||||
|
content.append(&count);
|
||||||
|
|
||||||
|
let grid = gtk4::FlowBox::builder()
|
||||||
|
.selection_mode(gtk4::SelectionMode::None)
|
||||||
|
.homogeneous(true)
|
||||||
|
.min_children_per_line(1)
|
||||||
|
.max_children_per_line(4)
|
||||||
|
.row_spacing(4)
|
||||||
|
.column_spacing(4)
|
||||||
|
.build();
|
||||||
|
for (place, photo) in photos {
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.can_shrink(true)
|
||||||
|
.width_request(SPOT_EDGE)
|
||||||
|
.height_request(SPOT_EDGE)
|
||||||
|
.build();
|
||||||
|
spot_thumb(ui, &photo, &picture);
|
||||||
|
let button = gtk4::Button::builder()
|
||||||
|
.child(&picture)
|
||||||
|
.tooltip_text(format_capture_time(photo.capture_time))
|
||||||
|
.build();
|
||||||
|
button.add_css_class("flat");
|
||||||
|
button.add_css_class("places-spot-photo");
|
||||||
|
let ui_open = ui.clone();
|
||||||
|
let popover_open = popover.clone();
|
||||||
|
button.connect_clicked(move |_| {
|
||||||
|
popover_open.popdown();
|
||||||
|
open_place_photo(&ui_open, place.clone(), photo.uid.clone());
|
||||||
|
});
|
||||||
|
grid.append(&button);
|
||||||
|
}
|
||||||
|
schedule_thumbs(ui);
|
||||||
|
let scroller = gtk4::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.propagate_natural_width(true)
|
||||||
|
.propagate_natural_height(true)
|
||||||
|
.max_content_height(360)
|
||||||
|
.child(&grid)
|
||||||
|
.build();
|
||||||
|
content.append(&scroller);
|
||||||
|
popover.set_child(Some(&content));
|
||||||
|
// Gone with its last use; its parent is the map, which stays.
|
||||||
|
popover.connect_closed(|popover| {
|
||||||
|
let popover = popover.clone();
|
||||||
|
glib::idle_add_local_once(move || popover.unparent());
|
||||||
|
});
|
||||||
|
popover.popup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show photo's thumbnail in `picture`, sharing one another widget is
|
||||||
|
/// already waiting for.
|
||||||
|
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
||||||
|
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
|
||||||
|
match waiting {
|
||||||
|
Some(waiting) => {
|
||||||
|
waiting
|
||||||
|
.bind_property("paintable", picture, "paintable")
|
||||||
|
.sync_create()
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
None => want_thumb(ui, photo, picture),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the map the photos of town `id` with where each was taken.
|
||||||
|
fn load_place_locations(ui: &Rc<Ui>, id: u32) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PlaceLocations { id });
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::PhotoLocations { items })) => {
|
||||||
|
ui.gallery.places_map.set_locations(id, items)
|
||||||
|
}
|
||||||
|
// The map keeps showing the town as one marker.
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
tracing::debug!("place locations failed: {message}")
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => tracing::debug!("place locations: no reply"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `place` and then photo `uid` in it, paging through the town until
|
||||||
|
/// the photo has loaded.
|
||||||
|
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
|
||||||
|
open_place(ui, place);
|
||||||
|
view_when_loaded(ui, uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
|
||||||
|
let ui_page = ui.clone();
|
||||||
|
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
|
||||||
|
let ui = ui_page;
|
||||||
|
if find_photo_index(&ui.gallery.model, &uid).is_some() {
|
||||||
|
open_photo_viewer(&ui, uid);
|
||||||
|
} else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
|
||||||
|
view_when_loaded(&ui, uid);
|
||||||
|
ui.gallery.burst.set(true);
|
||||||
|
load_gallery(&ui, true);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one place in the gallery: the timeline view, paged from that town.
|
||||||
|
fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
||||||
|
ui.gallery.title.set_title(&place.name);
|
||||||
|
ui.gallery.title.set_subtitle(&place_subtitle(&place));
|
||||||
|
*ui.gallery.place.borrow_mut() = Some(place);
|
||||||
|
ui.gallery.timeline_stale.set(true);
|
||||||
|
|
||||||
|
// As in an album: no filters, no Upload, no view switch — back leads out.
|
||||||
|
ui.gallery.filters.set_visible(false);
|
||||||
|
ui.gallery.view_switch.set_visible(false);
|
||||||
|
ui.gallery.upload.set_visible(false);
|
||||||
|
ui.gallery.back.set_visible(true);
|
||||||
|
ui.gallery.kind.set(None);
|
||||||
|
ui.gallery.range.set(None);
|
||||||
|
|
||||||
|
ui.gallery.content.set_visible_child_name("timeline");
|
||||||
|
load_gallery(ui, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave an open place, restoring the timeline's header. A no-op when no place
|
||||||
|
/// is open.
|
||||||
|
pub(crate) fn close_place(ui: &Rc<Ui>) {
|
||||||
|
if ui.gallery.place.borrow_mut().take().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.gallery.title.set_title(&gettext("Photos"));
|
||||||
|
ui.gallery.view_switch.set_visible(true);
|
||||||
|
ui.gallery.upload.set_visible(true);
|
||||||
|
ui.gallery.back.set_visible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the place grid's own status page (loading, empty, or an error).
|
||||||
|
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||||
|
ui.gallery.places_status.set_icon_name(Some(icon));
|
||||||
|
ui.gallery.places_status.set_title(title);
|
||||||
|
ui.gallery.places_status.set_description(Some(description));
|
||||||
|
ui.gallery.places_stack.set_visible_child_name("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire the Places toggle of the view switcher.
|
||||||
|
pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
||||||
|
let ui_places = ui.clone();
|
||||||
|
ui.gallery.places_btn.clone().connect_toggled(move |btn| {
|
||||||
|
if btn.is_active() {
|
||||||
|
show_places(&ui_places);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_layout = ui.clone();
|
||||||
|
ui.gallery.places_map_btn.connect_toggled(move |_| {
|
||||||
|
// A status page (loading, empty, an error) stays until a listing
|
||||||
|
// replaces it; only the grid and the map swap.
|
||||||
|
if ui_layout
|
||||||
|
.gallery
|
||||||
|
.places_stack
|
||||||
|
.visible_child_name()
|
||||||
|
.as_deref()
|
||||||
|
!= Some("status")
|
||||||
|
{
|
||||||
|
show_places_layout(&ui_layout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let map = &ui.gallery.places_map;
|
||||||
|
let ui_open = ui.clone();
|
||||||
|
map.connect_open(move |place| open_place(&ui_open, place));
|
||||||
|
let ui_photo = ui.clone();
|
||||||
|
map.connect_open_photo(move |place, uid| open_place_photo(&ui_photo, place, uid));
|
||||||
|
let ui_locations = ui.clone();
|
||||||
|
map.connect_want_locations(move |id| load_place_locations(&ui_locations, id));
|
||||||
|
let ui_thumb = ui.clone();
|
||||||
|
map.connect_want_thumb(move |photo| want_map_thumb(&ui_thumb, &photo));
|
||||||
|
let ui_spot = ui.clone();
|
||||||
|
map.connect_open_spot(move |photos, at| show_spot_photos(&ui_spot, photos, at));
|
||||||
|
map.set_online(ui.dirs.load_config().online_map, ui.dirs.cache_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to the Places map and zoom in on `(latitude, longitude)`, where a
|
||||||
|
/// photo in the viewer was taken.
|
||||||
|
pub(crate) fn show_on_map(ui: &Rc<Ui>, latitude: f64, longitude: f64) {
|
||||||
|
ui.gallery.places_map_btn.set_active(true);
|
||||||
|
if ui.gallery.places_btn.is_active() {
|
||||||
|
// Already in Places, perhaps inside a town: back out to the listing.
|
||||||
|
show_places(ui);
|
||||||
|
} else {
|
||||||
|
ui.gallery.places_btn.set_active(true);
|
||||||
|
}
|
||||||
|
ui.gallery.places_map.show_spot(latitude, longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the places as cards or on the map, as the Grid/Map toggle says.
|
||||||
|
fn show_places_layout(ui: &Rc<Ui>) {
|
||||||
|
let layout = if ui.gallery.places_map_btn.is_active() {
|
||||||
|
"map"
|
||||||
|
} else {
|
||||||
|
"grid"
|
||||||
|
};
|
||||||
|
ui.gallery.places_stack.set_visible_child_name(layout);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -44,8 +44,10 @@ pub(crate) struct StatusState {
|
|||||||
pub(crate) download_limit_row: adw::SpinRow,
|
pub(crate) download_limit_row: adw::SpinRow,
|
||||||
/// Shows where the primary mount lives; its Change button picks a new one.
|
/// Shows where the primary mount lives; its Change button picks a new one.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
/// "Proton purple accent" toggle. Guarded by [`Self::settings_suppress`].
|
/// "Proton theme" toggle. Guarded by [`Self::settings_suppress`].
|
||||||
pub(crate) accent_row: adw::SwitchRow,
|
pub(crate) theme_row: adw::SwitchRow,
|
||||||
|
/// Street map on the Places map. Guarded by [`Self::settings_suppress`].
|
||||||
|
pub(crate) online_map_row: adw::SwitchRow,
|
||||||
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
||||||
pub(crate) tray_row: adw::SwitchRow,
|
pub(crate) tray_row: adw::SwitchRow,
|
||||||
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
|
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
|
||||||
@@ -139,7 +141,8 @@ pub(crate) struct MainWidgets {
|
|||||||
/// Shows the active mountpoint; its suffix button picks a new one.
|
/// Shows the active mountpoint; its suffix button picks a new one.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
pub(crate) mountpoint_button: gtk4::Button,
|
pub(crate) mountpoint_button: gtk4::Button,
|
||||||
pub(crate) accent_row: adw::SwitchRow,
|
pub(crate) theme_row: adw::SwitchRow,
|
||||||
|
pub(crate) online_map_row: adw::SwitchRow,
|
||||||
pub(crate) tray_row: adw::SwitchRow,
|
pub(crate) tray_row: adw::SwitchRow,
|
||||||
pub(crate) language_row: adw::ComboRow,
|
pub(crate) language_row: adw::ComboRow,
|
||||||
}
|
}
|
||||||
@@ -207,13 +210,20 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
let appearance_group = adw::PreferencesGroup::builder()
|
let appearance_group = adw::PreferencesGroup::builder()
|
||||||
.title(gettext("Appearance"))
|
.title(gettext("Appearance"))
|
||||||
.build();
|
.build();
|
||||||
let accent_row = adw::SwitchRow::builder()
|
let theme_row = adw::SwitchRow::builder()
|
||||||
.title(gettext("Proton purple accent"))
|
.title(gettext("Proton theme"))
|
||||||
.subtitle(gettext(
|
.subtitle(gettext(
|
||||||
"Use the Proton brand color instead of the system accent color",
|
"Use Proton's colors instead of the system theme and accent color",
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
appearance_group.add(&accent_row);
|
appearance_group.add(&theme_row);
|
||||||
|
let online_map_row = adw::SwitchRow::builder()
|
||||||
|
.title(gettext("Street map for Places"))
|
||||||
|
.subtitle(gettext(
|
||||||
|
"Show streets and town names from OpenFreeMap. It learns which parts of the world you look at.",
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
appearance_group.add(&online_map_row);
|
||||||
let tray_row = adw::SwitchRow::builder()
|
let tray_row = adw::SwitchRow::builder()
|
||||||
.title(gettext("Show tray icon"))
|
.title(gettext("Show tray icon"))
|
||||||
.subtitle(gettext("Sync status and quick actions in the panel"))
|
.subtitle(gettext("Sync status and quick actions in the panel"))
|
||||||
@@ -395,7 +405,8 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
purge_button,
|
purge_button,
|
||||||
mountpoint_row,
|
mountpoint_row,
|
||||||
mountpoint_button,
|
mountpoint_button,
|
||||||
accent_row,
|
theme_row,
|
||||||
|
online_map_row,
|
||||||
tray_row,
|
tray_row,
|
||||||
language_row,
|
language_row,
|
||||||
}
|
}
|
||||||
@@ -455,7 +466,7 @@ pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
|||||||
pub(crate) const MIB: f64 = 1024.0 * 1024.0;
|
pub(crate) const MIB: f64 = 1024.0 * 1024.0;
|
||||||
|
|
||||||
/// Wire the Preferences controls: the cache-budget editor, the purge button, the
|
/// Wire the Preferences controls: the cache-budget editor, the purge button, the
|
||||||
/// start-on-login switch, the mountpoint chooser and the accent toggle. Initial widget state is
|
/// start-on-login switch, the mountpoint chooser and the theme toggle. Initial widget state is
|
||||||
/// read once from config / systemd here (the refresh loop owns only the live
|
/// read once from config / systemd here (the refresh loop owns only the live
|
||||||
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
|
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
|
||||||
/// programmatic populate so the change handlers don't fire on it.
|
/// programmatic populate so the change handlers don't fire on it.
|
||||||
@@ -482,8 +493,9 @@ pub(crate) fn wire_settings(
|
|||||||
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
|
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
|
||||||
ui.status.autostart_row.set_active(service::is_enabled());
|
ui.status.autostart_row.set_active(service::is_enabled());
|
||||||
ui.status
|
ui.status
|
||||||
.accent_row
|
.theme_row
|
||||||
.set_active(config.proton_accent.unwrap_or(false));
|
.set_active(config.proton_theme.unwrap_or(false));
|
||||||
|
ui.status.online_map_row.set_active(config.online_map);
|
||||||
ui.status.tray_row.set_active(!config.tray_hidden);
|
ui.status.tray_row.set_active(!config.tray_hidden);
|
||||||
ui.status
|
ui.status
|
||||||
.language_row
|
.language_row
|
||||||
@@ -585,20 +597,45 @@ pub(crate) fn wire_settings(
|
|||||||
let ui_mp = ui.clone();
|
let ui_mp = ui.clone();
|
||||||
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
||||||
|
|
||||||
// Accent: saved with the rest of the config, applied right away.
|
// Theme: saved with the rest of the config, applied right away.
|
||||||
let ui_accent = ui.clone();
|
let ui_theme = ui.clone();
|
||||||
ui.status.accent_row.connect_active_notify(move |row| {
|
ui.status.theme_row.connect_active_notify(move |row| {
|
||||||
if ui_accent.status.settings_suppress.get() {
|
if ui_theme.status.settings_suppress.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let on = row.is_active();
|
let on = row.is_active();
|
||||||
set_proton_accent(on);
|
set_proton_theme(on);
|
||||||
let mut config = ui_accent.dirs.load_config();
|
// Folders swap their icon with the theme; have every row bound again.
|
||||||
config.proton_accent = Some(on);
|
let rows = ui_theme.browser.model.n_items();
|
||||||
if let Err(e) = ui_accent.dirs.save_config(&config) {
|
ui_theme.browser.model.items_changed(0, rows, rows);
|
||||||
|
let mut config = ui_theme.dirs.load_config();
|
||||||
|
config.proton_theme = Some(on);
|
||||||
|
if let Err(e) = ui_theme.dirs.save_config(&config) {
|
||||||
toast_error(
|
toast_error(
|
||||||
&ui_accent,
|
&ui_theme,
|
||||||
&gettext("Couldn't save the accent color"),
|
&gettext("Couldn't save the theme"),
|
||||||
|
&e.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Street map: saved, and the Places map switches over right away.
|
||||||
|
let ui_map = ui.clone();
|
||||||
|
ui.status.online_map_row.connect_active_notify(move |row| {
|
||||||
|
if ui_map.status.settings_suppress.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let on = row.is_active();
|
||||||
|
ui_map
|
||||||
|
.gallery
|
||||||
|
.places_map
|
||||||
|
.set_online(on, ui_map.dirs.cache_dir());
|
||||||
|
let mut config = ui_map.dirs.load_config();
|
||||||
|
config.online_map = on;
|
||||||
|
if let Err(e) = ui_map.dirs.save_config(&config) {
|
||||||
|
toast_error(
|
||||||
|
&ui_map,
|
||||||
|
&gettext("Couldn't save the map setting"),
|
||||||
&e.to_string(),
|
&e.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
|||||||
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
ui.details.details_suppress.set(true);
|
ui.details.details_suppress.set(true);
|
||||||
let d = &ui.details.details;
|
let d = &ui.details.details;
|
||||||
d.icon.set_icon_name(Some(icon_base_for(entry)));
|
d.icon.set_icon_name(Some(icon_for(entry)));
|
||||||
d.name.set_label(&entry.name);
|
d.name.set_label(&entry.name);
|
||||||
d.kind.set_label(&if entry.is_dir {
|
d.kind.set_label(&if entry.is_dir {
|
||||||
gettext("Folder")
|
gettext("Folder")
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ pub(crate) fn bind_file_thumbnail(
|
|||||||
let fallback_name = if symbolic_fallback {
|
let fallback_name = if symbolic_fallback {
|
||||||
format!("{}-symbolic", icon_base_for(entry))
|
format!("{}-symbolic", icon_base_for(entry))
|
||||||
} else {
|
} else {
|
||||||
icon_base_for(entry).to_string()
|
icon_for(entry).to_string()
|
||||||
};
|
};
|
||||||
fallback.set_icon_name(Some(&fallback_name));
|
fallback.set_icon_name(Some(&fallback_name));
|
||||||
fallback.set_visible(true);
|
fallback.set_visible(true);
|
||||||
|
|||||||
@@ -64,11 +64,31 @@ pub(crate) fn init() {
|
|||||||
tracing::warn!("bindtextdomain failed: {e}");
|
tracing::warn!("bindtextdomain failed: {e}");
|
||||||
}
|
}
|
||||||
let _ = gettextrs::bind_textdomain_codeset(DOMAIN, "UTF-8");
|
let _ = gettextrs::bind_textdomain_codeset(DOMAIN, "UTF-8");
|
||||||
|
// Country names come translated from the iso-codes catalogs, which live in
|
||||||
|
// the system's own locale directory.
|
||||||
|
let _ = gettextrs::bind_textdomain_codeset(ISO_3166_DOMAIN, "UTF-8");
|
||||||
if let Err(e) = gettextrs::textdomain(DOMAIN) {
|
if let Err(e) = gettextrs::textdomain(DOMAIN) {
|
||||||
tracing::warn!("textdomain failed: {e}");
|
tracing::warn!("textdomain failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The iso-codes text domain that translates ISO 3166-1 country names.
|
||||||
|
const ISO_3166_DOMAIN: &str = "iso_3166-1";
|
||||||
|
|
||||||
|
/// The name of the country with ISO 3166-1 code `code`, in the user's language.
|
||||||
|
/// The English names are the iso-codes project's own, so that project's
|
||||||
|
/// catalogs translate them; Kosovo has no ISO code of its own and is
|
||||||
|
/// translated here. An unknown code is shown as it is.
|
||||||
|
pub(crate) fn country_name(code: &str) -> String {
|
||||||
|
if code == "XK" {
|
||||||
|
return gettext("Kosovo");
|
||||||
|
}
|
||||||
|
match pdfs_core::places::country_name(code) {
|
||||||
|
Some(name) => gettextrs::dgettext(ISO_3166_DOMAIN, name),
|
||||||
|
None => code.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Make `code` the message language. gettext reads `LANGUAGE` first, but
|
/// Make `code` the message language. gettext reads `LANGUAGE` first, but
|
||||||
/// ignores it while the message locale is "C" or "C.UTF-8", so a system without
|
/// ignores it while the message locale is "C" or "C.UTF-8", so a system without
|
||||||
/// a configured locale also gets its message locale moved to a real one. Both
|
/// a configured locale also gets its message locale moved to a real one. Both
|
||||||
|
|||||||
@@ -12,6 +12,42 @@ Conventions:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## B99 — Conflict copies in synced folders are logged again on every start and shown as "Resolved"
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified). No daemon carrying the change has been restarted against the
|
||||||
|
affected copies yet.
|
||||||
|
**Found:** 2026-09-27. The Activity page listed the same six conflict copies every day, e.g.
|
||||||
|
`Conflict [EMBER] Oshi no Ko S2 - 12 (sync-conflict 1786815864).mkv` with `Differs from … · 24 times
|
||||||
|
since Sep 20, 13:13 · Resolved`. `find` showed all six copies still on disk, in `~/Videos` and
|
||||||
|
`~/Documents`. Both are on-demand sync folders. `pdfs conflicts` answered `No conflicts.`. The
|
||||||
|
journal showed the daemon mounting at 17:29:33, and the entries were stamped 17:30. That is the
|
||||||
|
sweep's 30-second warmup.
|
||||||
|
|
||||||
|
**Where:** `crates/pdfs-fuse/src/sweep.rs` (`flag_conflict`, `sweep_conflicts_once`),
|
||||||
|
`crates/pdfs-fuse/src/conflicts.rs` (`list_conflicts`), `crates/pdfs-fuse/src/control.rs`
|
||||||
|
(`ResolveConflict`).
|
||||||
|
|
||||||
|
**Cause.** There were two faults.
|
||||||
|
|
||||||
|
- The sweep kept its "already flagged" set only in memory. Every daemon start forgot it, so the
|
||||||
|
first pass logged every open conflict again.
|
||||||
|
- The sweep flags copies anywhere in the account's own tree. `list_conflicts` only reported copies
|
||||||
|
below My Files, and resolving only accepted paths under the primary mount. A copy in a synced
|
||||||
|
folder was logged but never listed, so the GUI read it as resolved. It also could not be
|
||||||
|
resolved from the GUI or the CLI.
|
||||||
|
|
||||||
|
**Fix.** `Core::conflict_path` names a copy by its mountpoint-relative path under My Files, or
|
||||||
|
by its absolute local path inside a synced folder. It reuses the roots that search already
|
||||||
|
resolves hits through. The listing uses it, and the sweep flags only copies it can name. So the
|
||||||
|
feed never holds a conflict the listing cannot answer for. `ResolveConflict` routes an absolute
|
||||||
|
path to the on-demand mount that serves it. For a copy in a mirror folder, it says to edit the
|
||||||
|
local file instead. Before logging, `flag_conflict` checks the activity log
|
||||||
|
(`Db::activity_has`), so a restart does not log the same copy again. The copy's name carries its
|
||||||
|
own timestamp, so the name identifies the copy. No schema change.
|
||||||
|
Regression test: `activity_has_finds_only_matching_kind_and_target`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## B98 — A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes
|
## B98 — A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes
|
||||||
|
|
||||||
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a
|
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a
|
||||||
|
|||||||
@@ -11,6 +11,78 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-09-28
|
||||||
|
|
||||||
|
Schema: **34**. Photos gain a `similar_hash` column for the similar-photo finder. It stays
|
||||||
|
empty until the finder is first opened, so updating reads and downloads nothing.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Similar photos.** The duplicate finder gains a Similar switch that also finds resized or
|
||||||
|
re-encoded copies and shots of one moment, by comparing the thumbnails. The first search
|
||||||
|
hashes every thumbnail the server has, with a progress bar; photos without one are not
|
||||||
|
downloaded and are counted as not compared. Every photo in a set starts ticked Keep, since
|
||||||
|
photos that look alike can be different shots, and a photo not kept goes to Trash with the
|
||||||
|
rest of its shot.
|
||||||
|
- **Photos on the map.** Every town is marked with one of its photos and a count, and nearby
|
||||||
|
towns merge into one marker until you zoom in. Zoomed in on a town, each photo gets its own
|
||||||
|
marker where it was taken; clicking one opens it in the viewer, and clicking photos taken
|
||||||
|
on one spot lists them all. "Show on map" in the photo viewer now goes to that spot on the
|
||||||
|
Places map instead of a web browser.
|
||||||
|
- **Street map for Places.** An opt-in setting under Appearance draws the map from
|
||||||
|
[OpenFreeMap](https://openfreemap.org/) (OpenStreetMap data), with streets, water, parks and
|
||||||
|
town names in your language. It is off by default because the tile server learns which parts
|
||||||
|
of the world you look at. Tiles are cached under the cache directory (at most 256 MB), and
|
||||||
|
the built-in outline shows wherever they haven't loaded.
|
||||||
|
- **Proton theme.** The "Proton purple accent" preference is now a whole Proton theme: the
|
||||||
|
Proton web apps' light and dark colours, not just the accent. It also holds when `gtk.css`
|
||||||
|
imports another GTK theme (Catppuccin, Fluent, ...), whose own colours used to show through
|
||||||
|
almost everywhere. Folders get Proton's purple folder icon rather than the icon theme's.
|
||||||
|
Anyone who had the accent turned on gets the theme.
|
||||||
|
- **Country borders on the Places map.** The offline map draws country borders, with sea and
|
||||||
|
land in their own colours.
|
||||||
|
|
||||||
|
## [2.3.0] — 2026-09-28
|
||||||
|
|
||||||
|
Schema: **33**. Photos gain their location; the first timeline refresh after updating reads
|
||||||
|
the metadata of the whole library once (nothing is downloaded), and the Places view shows how
|
||||||
|
far it has got.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Places.** A third Photos view groups photos by the town they were taken in, as cards or on a
|
||||||
|
world map. Towns are looked up offline in bundled GeoNames data (CC BY 4.0), and the map is
|
||||||
|
drawn offline from Natural Earth land outlines (public domain), so no map tiles are fetched.
|
||||||
|
Install `iso-codes`, a new optional dependency, for country names in your language.
|
||||||
|
- **On this day.** A strip at the top of the timeline shows a card for every earlier year with
|
||||||
|
photos taken on today's date.
|
||||||
|
- **Find duplicates.** Photos can list every set of byte-identical copies and move the copies
|
||||||
|
you don't keep to Trash. The copy in the most albums is kept by default.
|
||||||
|
- **Photos in no album.** A filter narrows the timeline to photos no album holds yet.
|
||||||
|
- **One Filters popover.** Favorites, "in no album" and the month jump now share a Filters
|
||||||
|
button, which shows how many filters are on and can clear them all at once.
|
||||||
|
- **Zoom-level grouping and range select.** Zooming out groups the timeline by month, then by
|
||||||
|
year. Shift+click selects a range, and dragging across the timeline selects what it sweeps.
|
||||||
|
- **Slideshow.** The photo viewer plays the loaded photos full screen, with F5 or a top-bar
|
||||||
|
button.
|
||||||
|
- **App icon in the app.** The sidebar header and the login page show the app's own icon.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Photos grid.** The timeline is laid out as square tiles that fill the window's width
|
||||||
|
exactly, and a theme's own `gtk.css` can no longer pad the tiles. It stays at its top, and at
|
||||||
|
the photo you were looking at, when the window is resized or zoomed.
|
||||||
|
- **Timeline shaking near the top.** Scrolling up to the "On this day" strip no longer makes the
|
||||||
|
whole page jump between two heights.
|
||||||
|
- **Filters while loading.** Changing a filter while a page was still loading could leave the
|
||||||
|
timeline showing the old filter.
|
||||||
|
|
||||||
|
## [2.2.2] — 2026-09-27
|
||||||
|
|
||||||
|
Schema: **32** (unchanged).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Conflicts in synced folders.** A conflict copy inside a synced folder is now listed under
|
||||||
|
Needs attention and can be resolved there or with `pdfs conflicts`. It used to be logged again
|
||||||
|
after every restart and marked "Resolved" while it was still on disk (B99).
|
||||||
|
|
||||||
## [2.2.1] — 2026-09-24
|
## [2.2.1] — 2026-09-24
|
||||||
|
|
||||||
Schema: **32** (unchanged).
|
Schema: **32** (unchanged).
|
||||||
@@ -1038,6 +1110,10 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
|||||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[2.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.4.0
|
||||||
|
|
||||||
|
[2.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.3.0
|
||||||
|
[2.2.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.2
|
||||||
[2.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.1
|
[2.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.2.1
|
||||||
[2.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0
|
[2.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0
|
||||||
[2.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.0.0
|
[2.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.0.0
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=2.2.1
|
pkgver=2.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -21,6 +21,7 @@ provides=('pdfs')
|
|||||||
# Files-on-demand mount + keyring need these at runtime.
|
# Files-on-demand mount + keyring need these at runtime.
|
||||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||||
'ffmpeg: video thumbnails'
|
'ffmpeg: video thumbnails'
|
||||||
|
'iso-codes: country names in your language under Places'
|
||||||
'xdg-utils: "Open folder" action from the tray'
|
'xdg-utils: "Open folder" action from the tray'
|
||||||
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||||
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-bin
|
pkgbase = proton-drive-for-linux-bin
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||||
pkgver = 2.2.0
|
pkgver = 2.3.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/narrrl/proton-drive-linux
|
url = https://github.com/narrrl/proton-drive-linux
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -15,26 +15,27 @@ pkgbase = proton-drive-for-linux-bin
|
|||||||
depends = hicolor-icon-theme
|
depends = hicolor-icon-theme
|
||||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||||
optdepends = ffmpeg: video thumbnails
|
optdepends = ffmpeg: video thumbnails
|
||||||
|
optdepends = iso-codes: country names in your language under Places
|
||||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||||
optdepends = kwallet: credential storage over the Secret Service API
|
optdepends = kwallet: credential storage over the Secret Service API
|
||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.2.0
|
provides = proton-drive-for-linux=2.3.0
|
||||||
provides = proton-drive-linux=2.2.0
|
provides = proton-drive-linux=2.3.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !strip
|
options = !strip
|
||||||
options = !debug
|
options = !debug
|
||||||
source = proton-drive-linux-2.2.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.2.0/proton-drive-linux-2.2.0-x86_64.tar.gz
|
source = proton-drive-linux-2.3.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.3.0/proton-drive-linux-2.3.0-x86_64.tar.gz
|
||||||
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux.desktop
|
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.3.0/packaging/io.narl.proton-drive-linux.desktop
|
||||||
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.3.0/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||||
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/io.narl.proton-drive-linux.svg
|
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.3.0/packaging/io.narl.proton-drive-linux.svg
|
||||||
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/packaging/proton-drive.service
|
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.3.0/packaging/proton-drive.service
|
||||||
source = LICENSE-2.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.2.0/LICENSE
|
source = LICENSE-2.3.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.3.0/LICENSE
|
||||||
sha256sums = 4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479
|
sha256sums = 1d313f30a6f1bc7c2089f3233c7ffcec32daaf17c2b6710e481d73f2f15f82f4
|
||||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-bin
|
pkgname=proton-drive-for-linux-bin
|
||||||
pkgver=2.2.0
|
pkgver=2.3.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -11,6 +11,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
|||||||
'hicolor-icon-theme')
|
'hicolor-icon-theme')
|
||||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||||
'ffmpeg: video thumbnails'
|
'ffmpeg: video thumbnails'
|
||||||
|
'iso-codes: country names in your language under Places'
|
||||||
'gnome-keyring: credential storage over the Secret Service API'
|
'gnome-keyring: credential storage over the Secret Service API'
|
||||||
'kwallet: credential storage over the Secret Service API'
|
'kwallet: credential storage over the Secret Service API'
|
||||||
'xdg-utils: "Open folder" action in the tray menu'
|
'xdg-utils: "Open folder" action in the tray menu'
|
||||||
@@ -30,7 +31,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$url/releases/download/$_tag/
|
|||||||
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||||
"proton-drive.service::$_raw/packaging/proton-drive.service"
|
"proton-drive.service::$_raw/packaging/proton-drive.service"
|
||||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||||
sha256sums=('4d746d83abd5174acb324236b6146c083a43b8c25e8d30e31c2a6d5b594ba479'
|
sha256sums=('1d313f30a6f1bc7c2089f3233c7ffcec32daaf17c2b6710e481d73f2f15f82f4'
|
||||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux-git
|
pkgbase = proton-drive-for-linux-git
|
||||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||||
pkgver = 2.2.0.r1.g82e9654
|
pkgver = 2.3.0.r1.gfde9c2d
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/narrrl/proton-drive-linux
|
url = https://github.com/narrrl/proton-drive-linux
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -18,13 +18,14 @@ pkgbase = proton-drive-for-linux-git
|
|||||||
depends = hicolor-icon-theme
|
depends = hicolor-icon-theme
|
||||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||||
optdepends = ffmpeg: video thumbnails
|
optdepends = ffmpeg: video thumbnails
|
||||||
|
optdepends = iso-codes: country names in your language under Places
|
||||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||||
optdepends = kwallet: credential storage over the Secret Service API
|
optdepends = kwallet: credential storage over the Secret Service API
|
||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-for-linux=2.2.0.r1.g82e9654
|
provides = proton-drive-for-linux=2.3.0.r1.gfde9c2d
|
||||||
provides = proton-drive-linux=2.2.0.r1.g82e9654
|
provides = proton-drive-linux=2.3.0.r1.gfde9c2d
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux
|
conflicts = proton-drive-for-linux
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux-git
|
pkgname=proton-drive-for-linux-git
|
||||||
pkgver=2.2.0.r1.g82e9654
|
pkgver=2.3.0.r1.gfde9c2d
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -12,6 +12,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
|||||||
makedepends=('cargo' 'git' 'gettext')
|
makedepends=('cargo' 'git' 'gettext')
|
||||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||||
'ffmpeg: video thumbnails'
|
'ffmpeg: video thumbnails'
|
||||||
|
'iso-codes: country names in your language under Places'
|
||||||
'gnome-keyring: credential storage over the Secret Service API'
|
'gnome-keyring: credential storage over the Secret Service API'
|
||||||
'kwallet: credential storage over the Secret Service API'
|
'kwallet: credential storage over the Secret Service API'
|
||||||
'xdg-utils: "Open folder" action in the tray menu'
|
'xdg-utils: "Open folder" action in the tray menu'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = proton-drive-for-linux
|
pkgbase = proton-drive-for-linux
|
||||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||||
pkgver = 2.2.0
|
pkgver = 2.3.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/narrrl/proton-drive-linux
|
url = https://github.com/narrrl/proton-drive-linux
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
@@ -17,18 +17,19 @@ pkgbase = proton-drive-for-linux
|
|||||||
depends = hicolor-icon-theme
|
depends = hicolor-icon-theme
|
||||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||||
optdepends = ffmpeg: video thumbnails
|
optdepends = ffmpeg: video thumbnails
|
||||||
|
optdepends = iso-codes: country names in your language under Places
|
||||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||||
optdepends = kwallet: credential storage over the Secret Service API
|
optdepends = kwallet: credential storage over the Secret Service API
|
||||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||||
provides = proton-drive-linux=2.2.0
|
provides = proton-drive-linux=2.3.0
|
||||||
provides = pdfs
|
provides = pdfs
|
||||||
conflicts = proton-drive-linux
|
conflicts = proton-drive-linux
|
||||||
conflicts = proton-drive-for-linux-bin
|
conflicts = proton-drive-for-linux-bin
|
||||||
conflicts = proton-drive-for-linux-git
|
conflicts = proton-drive-for-linux-git
|
||||||
options = !lto
|
options = !lto
|
||||||
source = proton-drive-for-linux-2.2.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.2.0.tar.gz
|
source = proton-drive-for-linux-2.3.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v2.3.0.tar.gz
|
||||||
sha256sums = 73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b
|
sha256sums = ac89c79170bba4fda85c924739b5248d9e324a1d45c30faee25c3c9389f297f1
|
||||||
|
|
||||||
pkgname = proton-drive-for-linux
|
pkgname = proton-drive-for-linux
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
|
|
||||||
pkgname=proton-drive-for-linux
|
pkgname=proton-drive-for-linux
|
||||||
pkgver=2.2.0
|
pkgver=2.3.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -12,6 +12,7 @@ depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
|||||||
makedepends=('cargo' 'gettext')
|
makedepends=('cargo' 'gettext')
|
||||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||||
'ffmpeg: video thumbnails'
|
'ffmpeg: video thumbnails'
|
||||||
|
'iso-codes: country names in your language under Places'
|
||||||
'gnome-keyring: credential storage over the Secret Service API'
|
'gnome-keyring: credential storage over the Secret Service API'
|
||||||
'kwallet: credential storage over the Secret Service API'
|
'kwallet: credential storage over the Secret Service API'
|
||||||
'xdg-utils: "Open folder" action in the tray menu'
|
'xdg-utils: "Open folder" action in the tray menu'
|
||||||
@@ -22,7 +23,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
|
|||||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||||
options=('!lto')
|
options=('!lto')
|
||||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('73f68211d008894c3254b5adca0acca7f9e1af90e89209c22ae0213aa75add4b')
|
sha256sums=('ac89c79170bba4fda85c924739b5248d9e324a1d45c30faee25c3c9389f297f1')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ Requires: xdg-utils
|
|||||||
|
|
||||||
# DE-specific; do not Require a single desktop environment.
|
# DE-specific; do not Require a single desktop environment.
|
||||||
Recommends: gnome-keyring
|
Recommends: gnome-keyring
|
||||||
|
Recommends: iso-codes
|
||||||
Recommends: gnome-shell-extension-appindicator
|
Recommends: gnome-shell-extension-appindicator
|
||||||
Recommends: kwallet
|
Recommends: kwallet
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ crates/pdfs-gui/src/app/pages/activity.rs
|
|||||||
crates/pdfs-gui/src/app/pages/albums.rs
|
crates/pdfs-gui/src/app/pages/albums.rs
|
||||||
crates/pdfs-gui/src/app/pages/browser.rs
|
crates/pdfs-gui/src/app/pages/browser.rs
|
||||||
crates/pdfs-gui/src/app/pages/devices.rs
|
crates/pdfs-gui/src/app/pages/devices.rs
|
||||||
|
crates/pdfs-gui/src/app/pages/duplicates.rs
|
||||||
crates/pdfs-gui/src/app/pages/locations.rs
|
crates/pdfs-gui/src/app/pages/locations.rs
|
||||||
crates/pdfs-gui/src/app/pages/login.rs
|
crates/pdfs-gui/src/app/pages/login.rs
|
||||||
crates/pdfs-gui/src/app/pages/photo_viewer.rs
|
crates/pdfs-gui/src/app/pages/photo_viewer.rs
|
||||||
crates/pdfs-gui/src/app/pages/photos.rs
|
crates/pdfs-gui/src/app/pages/photos.rs
|
||||||
|
crates/pdfs-gui/src/app/pages/places.rs
|
||||||
|
crates/pdfs-gui/src/app/pages/places_map.rs
|
||||||
crates/pdfs-gui/src/app/pages/shared.rs
|
crates/pdfs-gui/src/app/pages/shared.rs
|
||||||
crates/pdfs-gui/src/app/pages/shared_by_me.rs
|
crates/pdfs-gui/src/app/pages/shared_by_me.rs
|
||||||
crates/pdfs-gui/src/app/pages/status.rs
|
crates/pdfs-gui/src/app/pages/status.rs
|
||||||
|
|||||||
+750
-427
File diff suppressed because it is too large.
Load diff
+722
-424
File diff suppressed because it is too large.
Load diff
+751
-427
File diff suppressed because it is too large.
Load diff
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regenerate crates/pdfs-core/data/cities15000.tsv from a GeoNames dump.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
curl -LO https://download.geonames.org/export/dump/cities15000.zip
|
||||||
|
unzip cities15000.zip
|
||||||
|
scripts/geonames-cities.py cities15000.txt crates/pdfs-core/data/cities15000.tsv
|
||||||
|
|
||||||
|
GeoNames publishes the dump under CC BY 4.0; the About dialog credits it.
|
||||||
|
|
||||||
|
The output keeps id, name, latitude, longitude and country code, sorted by id.
|
||||||
|
Two kinds of rows are dropped, so that a photo lands in the town people would
|
||||||
|
name rather than in a part of it:
|
||||||
|
|
||||||
|
- Feature codes for sections of a city (PPLX, such as Kreuzberg) and for
|
||||||
|
places that no longer exist (PPLH, PPLQ, PPLW).
|
||||||
|
- Districts that GeoNames lists as towns of their own but that are named after
|
||||||
|
a town at least five times their size within 20 km in the same country,
|
||||||
|
such as "Paris 15 Vaugirard" or "Marseille 01".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SKIPPED_FEATURES = {"PPLX", "PPLH", "PPLQ", "PPLW"}
|
||||||
|
DISTRICT_RADIUS_KM = 20
|
||||||
|
DISTRICT_RATIO = 5
|
||||||
|
|
||||||
|
|
||||||
|
def distance_km(a, b):
|
||||||
|
lat1, lat2 = math.radians(a[0]), math.radians(b[0])
|
||||||
|
dlon = math.radians(b[1] - a[1])
|
||||||
|
h = (
|
||||||
|
math.sin((lat2 - lat1) / 2) ** 2
|
||||||
|
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
||||||
|
)
|
||||||
|
return 2 * 6371 * math.asin(min(1, math.sqrt(h)))
|
||||||
|
|
||||||
|
|
||||||
|
def main(source, target):
|
||||||
|
towns = []
|
||||||
|
with open(source, encoding="utf-8") as dump:
|
||||||
|
for line in dump:
|
||||||
|
f = line.rstrip("\n").split("\t")
|
||||||
|
if f[7] in SKIPPED_FEATURES:
|
||||||
|
continue
|
||||||
|
towns.append(
|
||||||
|
{
|
||||||
|
"id": int(f[0]),
|
||||||
|
"name": f[1],
|
||||||
|
"at": (float(f[4]), float(f[5])),
|
||||||
|
"country": f[8],
|
||||||
|
"population": int(f[14] or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
cells = {}
|
||||||
|
for town in towns:
|
||||||
|
key = (math.floor(town["at"][0]), math.floor(town["at"][1]))
|
||||||
|
cells.setdefault(key, []).append(town)
|
||||||
|
|
||||||
|
def is_district(town):
|
||||||
|
row, column = math.floor(town["at"][0]), math.floor(town["at"][1])
|
||||||
|
for dlat in (-1, 0, 1):
|
||||||
|
for dlon in (-1, 0, 1):
|
||||||
|
for other in cells.get((row + dlat, column + dlon), []):
|
||||||
|
if (
|
||||||
|
other is not town
|
||||||
|
and other["country"] == town["country"]
|
||||||
|
and town["name"].startswith(other["name"] + " ")
|
||||||
|
and other["population"] >= DISTRICT_RATIO * town["population"]
|
||||||
|
and distance_km(town["at"], other["at"]) <= DISTRICT_RADIUS_KM
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
kept = sorted((t for t in towns if not is_district(t)), key=lambda t: t["id"])
|
||||||
|
with open(target, "w", encoding="utf-8") as out:
|
||||||
|
for t in kept:
|
||||||
|
lat, lon = t["at"]
|
||||||
|
out.write(f"{t['id']}\t{t['name']}\t{lat:.4f}\t{lon:.4f}\t{t['country']}\n")
|
||||||
|
print(f"{len(kept)} towns written", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1], sys.argv[2])
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regenerate crates/pdfs-gui/data/land.txt from Natural Earth's land polygons,
|
||||||
|
and crates/pdfs-gui/data/borders.txt from its country borders.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
curl -LO https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_land.geojson
|
||||||
|
scripts/natural-earth-land.py ne_50m_land.geojson crates/pdfs-gui/data/land.txt
|
||||||
|
curl -LO https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_boundary_lines_land.geojson
|
||||||
|
scripts/natural-earth-land.py ne_50m_admin_0_boundary_lines_land.geojson crates/pdfs-gui/data/borders.txt
|
||||||
|
|
||||||
|
Natural Earth is in the public domain; the About dialog credits it anyway.
|
||||||
|
|
||||||
|
The output is one ring per line, as space-separated "longitude,latitude"
|
||||||
|
pairs rounded to two decimals (about a kilometre). Each ring is simplified
|
||||||
|
with Ramer-Douglas-Peucker to TOLERANCE degrees, which is finer than the map
|
||||||
|
can show at its closest zoom, and rings left with fewer than four points
|
||||||
|
(islets smaller than the tolerance) are dropped. Holes (lakes) are dropped
|
||||||
|
too: the map fills the land and draws nothing on top of it. Borders are lines
|
||||||
|
rather than rings, simplified the same way; a line needs two points.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
TOLERANCE = 0.04
|
||||||
|
|
||||||
|
|
||||||
|
def point_line_distance(p, a, b):
|
||||||
|
(x, y), (x1, y1), (x2, y2) = p, a, b
|
||||||
|
dx, dy = x2 - x1, y2 - y1
|
||||||
|
if dx == 0 and dy == 0:
|
||||||
|
return ((x - x1) ** 2 + (y - y1) ** 2) ** 0.5
|
||||||
|
t = max(0, min(1, ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy)))
|
||||||
|
return ((x - x1 - t * dx) ** 2 + (y - y1 - t * dy) ** 2) ** 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def simplify(points):
|
||||||
|
"""Ramer-Douglas-Peucker, iterative so long coastlines don't recurse deep."""
|
||||||
|
keep = [False] * len(points)
|
||||||
|
keep[0] = keep[-1] = True
|
||||||
|
stack = [(0, len(points) - 1)]
|
||||||
|
while stack:
|
||||||
|
first, last = stack.pop()
|
||||||
|
worst, index = 0.0, None
|
||||||
|
for i in range(first + 1, last):
|
||||||
|
d = point_line_distance(points[i], points[first], points[last])
|
||||||
|
if d > worst:
|
||||||
|
worst, index = d, i
|
||||||
|
if index is not None and worst > TOLERANCE:
|
||||||
|
keep[index] = True
|
||||||
|
stack.append((first, index))
|
||||||
|
stack.append((index, last))
|
||||||
|
return [p for p, k in zip(points, keep) if k]
|
||||||
|
|
||||||
|
|
||||||
|
def main(source, target):
|
||||||
|
features = json.load(open(source))["features"]
|
||||||
|
lines = []
|
||||||
|
for feature in features:
|
||||||
|
geometry = feature["geometry"]
|
||||||
|
kind = geometry["type"]
|
||||||
|
if kind in ("LineString", "MultiLineString"):
|
||||||
|
paths = (
|
||||||
|
geometry["coordinates"]
|
||||||
|
if kind == "MultiLineString"
|
||||||
|
else [geometry["coordinates"]]
|
||||||
|
)
|
||||||
|
shortest = 2
|
||||||
|
else:
|
||||||
|
polygons = (
|
||||||
|
geometry["coordinates"]
|
||||||
|
if kind == "MultiPolygon"
|
||||||
|
else [geometry["coordinates"]]
|
||||||
|
)
|
||||||
|
paths = [polygon[0] for polygon in polygons]
|
||||||
|
shortest = 4
|
||||||
|
for path in paths:
|
||||||
|
path = simplify(path)
|
||||||
|
if len(path) < shortest:
|
||||||
|
continue
|
||||||
|
lines.append(" ".join(f"{lon:.2f},{lat:.2f}" for lon, lat in path))
|
||||||
|
with open(target, "w") as out:
|
||||||
|
out.write("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1], sys.argv[2])
|
||||||
Reference in new issue
Block a user