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, base: String, folderNames: Map, sent: (String) -> DriveLedger.Entry?, skip: Set, zone: TimeZone, sameBytes: (DriveFile, DriveLedger.Entry) -> Boolean, ): List { val out = ArrayList() 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 } } /** `//`, 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 }