1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
package org.meshbay.client.drive
import org.meshbay.client.photos.PhotoPlan
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** One file found in a chosen folder, through the Storage Access Framework. */
data class DriveFile(
/** The folder it was found under, as granted (a tree URI). */
val tree: String,
/** Its document id, stable for the life of the file (`primary:Documents/a.pdf`). */
val docId: String,
/** Its folder inside the chosen one, `/`-separated, empty at the top. */
val sub: String,
val name: String,
val size: Long,
/** Milliseconds. */
val modified: Long,
val mime: String,
)
/** A file to send: a new one, or a new version of one already sent. */
data class DrivePending(val file: DriveFile, val edited: Boolean, val dir: String, val name: String)
/**
* The rules of the files backup (docs/MESHBAY_DESIGN.md §9.13), pure, so the
* JVM tests hold them: which folders may be chosen, what a run sends, and the
* names the node will accept.
*/
object DrivePlan {
/**
* The phone's media folders: what lives there is the photo backup's, and
* taking it here too would store every photo twice on the node.
*/
private val MEDIA = setOf("dcim", "pictures", "movies")
/** `primary:Documents/Scans` → `Documents/Scans`; the part after the volume. */
fun pathOf(docId: String): String = docId.substringAfter(':', "").trim('/')
/**
* Why a folder cannot be chosen, or null when it can: the whole of a
* volume (it holds the media folders), or a media folder or anything in one.
*/
fun refusal(treeDocId: String): String? {
val path = pathOf(treeDocId)
if (path.isEmpty()) return "whole_storage"
if (path.split('/').first().lowercase(Locale.ROOT) in MEDIA) return "media_folder"
return null
}
/**
* A run's files: new ones, and new versions of ones sent (beside them,
* never in their place). `skip` holds the files the photo backup already
* sends, as lowercase `relative/path/name`. Smallest first: a run cut short
* has saved the most files.
*/
fun plan(
files: List<DriveFile>, base: String, folderNames: Map<String, String>,
sent: (String) -> DriveLedger.Entry?, skip: Set<String>, zone: TimeZone,
sameBytes: (DriveFile, DriveLedger.Entry) -> Boolean,
): List<DrivePending> {
val out = ArrayList<DrivePending>()
for (f in files) {
if (f.name.startsWith('.')) continue
if (skip.contains(pathOf(f.docId).lowercase(Locale.ROOT))) continue
val top = folderNames[f.tree] ?: continue
val dir = dirFor(base, top, f.sub)
val was = sent(f.docId)
if (was == null) {
out += DrivePending(f, false, dir, safeName(f.name))
} else if (was.modified != f.modified || was.size != f.size) {
if (was.size == f.size && sameBytes(f, was)) continue
out += DrivePending(f, true, dir, editedName(f, zone))
}
}
return out.sortedBy { it.file.size }
}
/** `<base>/<chosen folder>/<sub>`, each level a name the node accepts; no chosen-folder level when `top` is empty. */
fun dirFor(base: String, top: String, sub: String): String =
(listOf(base) + listOf(top).filter { it.isNotEmpty() }.map(::safeName) +
sub.split('/').filter { it.isNotEmpty() }.map(::safeName)).joinToString("/")
/**
* The name as is when the node accepts it (PhotoPlan.UPLOAD_NAME, the
* node's own rule); otherwise its refused characters become `_`, and a
* name that cannot start as the node wants is given a `file-` prefix.
*/
fun safeName(name: String): String {
if (PhotoPlan.UPLOAD_NAME.matches(name)) return name
var s = name.map { c -> if (c.isLetterOrDigit() || c in " _.-()[]'’,&+#@") c else '_' }.joinToString("")
.trimEnd(' ', '.')
if (s.isEmpty() || !s.first().isLetterOrDigit()) s = "file-$s"
s = s.take(MAX_NAME).trimEnd(' ', '.')
return if (PhotoPlan.UPLOAD_NAME.matches(s)) s else "file"
}
/** A new version lands beside the one sent, its date in its name. */
fun editedName(f: DriveFile, zone: TimeZone): String {
val base = safeName(f.name)
val dot = base.lastIndexOf('.')
val stem = if (dot > 0) base.substring(0, dot) else base
val ext = if (dot > 0) base.substring(dot) else ""
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).apply { timeZone = zone }.format(Date(f.modified))
val suffix = "-modified-$stamp$ext"
return stem.take(MAX_NAME - suffix.length) + suffix
}
private const val MAX_NAME = 128
}
|