All files / src/state drives.ts

96.93% Statements 95/98
94.91% Branches 56/59
100% Functions 8/8
96.93% Lines 95/98

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230                                                                            2x                       2x     1216x 1216x 1216x 1216x     1216x   23x 23x 23x 23x     23x 23x                       2x 1216x 1216x 27x 27x 27x 55x 1x 1x 55x 25x 25x 51x 28x 26x 28x 28x 28x 23x 28x 28x 28x 25x 25x                           2x 19x 19x 19x 19x 1x 1x 1x 1x 1x         18x 19x 3x 3x 3x 18x 18x 18x 18x 18x     2x 5x 5x 5x 4x 4x                 2x 1x 1x 1x 1x 1x                       2x 16x 16x 2x 2x 2x 2x 2x   2x 2x 2x                                             2x 18x 18x 18x 12x 12x 12x 18x 1x 1x 18x     10x 18x         18x 18x  
/**
 * The Google Drive folders this browser has been told to keep (SPECS §20, §24.7).
 *
 * A Drive folder is the same kind of thing as a remembered store or a bucket
 * scope: a place the reader connected to by pasting an address, which they will
 * want again next week and should not have to find twice. So it is kept the same
 * way — one `localStorage` key under the app's prefix, so the stored-data screen
 * can name it and *forget everything* sweeps it without knowing it exists
 * (`state/stored.ts`) — and it is kept *only* that way. A row is a label and a
 * folder id, never a listing and never a token: the listing is a round trip made
 * with the reader's own account when they open the folder, and the token that
 * authorises it is held in memory by `s3/credentials.ts`'s bearer registry and
 * is never written down (§24.8).
 *
 * **Nothing here fetches anything, and nothing here knows what Drive is.** A
 * stored row becomes a `ConnectedFolder` in `browse/gdrive.ts`, which is the
 * module that talks to the API; this one is fifty lines of storage, which is why
 * it can be read in a build that has no sign-in at all.
 *
 * ## Why a folder id and not a link
 *
 * What the reader has in their hand is a browser address bar —
 * `https://drive.google.com/drive/u/0/folders/1AbC…` — and what the API wants is
 * the id in it. Storing the link and parsing it at every listing would be the
 * same parse run a thousand times against a value that cannot change; storing
 * the id means a row that fails is a row whose *id* is wrong, which is a
 * sentence a refusal can say. {@link folderIdFrom} is the one place the two
 * forms meet, and it answers `null` rather than guessing, because the guess is
 * a folder that 404s at the moment the reader opens it rather than at the moment
 * they paste it.
 *
 * The id `root` is Drive's own name for My Drive, not an invention here: the
 * files API accepts it wherever it accepts a folder id, so "the whole of my
 * Drive" needs no special case anywhere downstream.
 */
 
import type { StoredItem } from './stored'
 
const KEY = 'chromosomix:drives'
 
/** One connected Drive folder: what to call it, and which folder it is. */
export interface DriveRow {
  /** `drive:<n>`, minted here and stable across edits. */
  id: string
  /** What the reader calls it. Shown in the panel; never sent anywhere. */
  label: string
  /** A Drive folder id, or `root` for My Drive. */
  folderId: string
}
 
const listeners = new Set<() => void>()
 
/** Storage that cannot throw — `s3/credentials.ts`'s reasoning, and its shape. */
function readRaw(): string | null {
  try {
    return window.localStorage.getItem(KEY)
  } catch {
    return null
  }
}
 
function write(rows: readonly DriveRow[]): void {
  try {
    window.localStorage.setItem(KEY, JSON.stringify(rows))
  } catch {
    /* a browser refusing storage still runs; the folder just is not kept */
  }
  for (const listener of listeners) listener()
}
 
/**
 * Every connected Drive folder, validated.
 *
 * Untrusted input like every other stored value in this app: a row is dropped
 * rather than repaired, because the repair for a missing folder id is a row in
 * the Files panel that answers a refusal when it is clicked. The label is the
 * one field that is repaired, to the folder id, since a row with no name is
 * still a folder the reader connected and a nameless button is worse than an
 * ugly one.
 */
export function readDriveRows(): DriveRow[] {
  const raw = readRaw()
  if (raw === null) return []
  let parsed: unknown
  try {
    parsed = JSON.parse(raw)
  } catch {
    return []
  }
  if (!Array.isArray(parsed)) return []
  const out: DriveRow[] = []
  const seen = new Set<string>()
  for (const entry of parsed) {
    if (typeof entry !== 'object' || entry === null) continue
    const row = entry as Record<string, unknown>
    const id = typeof row['id'] === 'string' ? row['id'] : ''
    const folderId = typeof row['folderId'] === 'string' ? row['folderId'] : ''
    if (id === '' || folderId === '' || seen.has(id)) continue
    seen.add(id)
    const label = typeof row['label'] === 'string' && row['label'] !== '' ? row['label'] : folderId
    out.push({ id, label, folderId })
  }
  return out
}
 
/**
 * Connect a Drive folder, or rename one already connected.
 *
 * Keyed on the **folder id** rather than on a minted id, which is what makes
 * connecting My Drive twice one row rather than two rows called the same thing
 * pointing at the same place — the rule `state/stores.ts` states for a URL, for
 * the same reason. The label of the later press wins, because it is the one the
 * reader has just typed.
 *
 * Answers with the stored row, because the caller's next move is to open the
 * folder and it needs the id to name it by.
 */
export function addDriveRow(row: { label: string; folderId: string }): DriveRow {
  const rows = readDriveRows()
  const label = row.label.trim() === '' ? row.folderId : row.label.trim()
  const found = rows.findIndex((held) => held.folderId === row.folderId)
  if (found >= 0) {
    const updated: DriveRow = { ...rows[found], label }
    rows[found] = updated
    write(rows)
    return updated
  }
  // The highest `drive:<n>` in use plus one, rather than the list's length: rows
  // are removed, and a length-based id would hand a new folder the id of one the
  // reader had just disconnected — which is the Files panel's remembered "last
  // folder" opening somebody else's Drive on the next reload.
  let next = 1
  for (const held of rows) {
    const number = /^drive:(\d+)$/.exec(held.id)
    if (number !== null) next = Math.max(next, Number(number[1]) + 1)
  }
  const added: DriveRow = { id: `drive:${next}`, label, folderId: row.folderId }
  rows.push(added)
  write(rows)
  return added
}
 
/** Forget one. Unknown ids are not an error — the row may be gone already. */
export function removeDriveRow(id: string): void {
  const rows = readDriveRows()
  const kept = rows.filter((row) => row.id !== id)
  if (kept.length === rows.length) return
  write(kept)
}
 
/**
 * Hear about a folder being connected or disconnected. Returns the unsubscribe.
 *
 * The Connect dialog and the Files panel can be looking at this list at the same
 * moment — the dialog is opened *from* the panel — so a list that refreshed only
 * on reload would show a folder in one of them and not the other.
 */
export function subscribeDrives(listener: () => void): () => void {
  listeners.add(listener)
  return () => {
    listeners.delete(listener)
  }
}
 
/**
 * What this browser is keeping about Drive, as a line for the stored-data screen.
 *
 * Named rather than counted, on the rule `state/stored.ts` follows for the
 * folders and the stores: a Drive folder is somewhere the reader connected to, and
 * the labels are the part worth reading back before deciding what to do on a
 * borrowed machine. What is *not* here is the credential, and the line says so —
 * the token that lists these folders is held in memory and never written (§24.8),
 * so a reader who sees this line has been told the whole of what is on the disk.
 */
export function storedLines(): StoredItem[] {
  const rows = readDriveRows()
  if (rows.length === 0) return []
  return [
    {
      label: 'Drive folders you connected',
      detail:
        `${rows.map((row) => row.label).join(', ')} — a name and a folder id each, listed with ` +
        'your own Google account when you are signed in. No token is kept here.',
    },
  ]
}
 
/**
 * The folder id in whatever the reader pasted, or `null` if there is none.
 *
 * Three shapes, which are the three things somebody can have in hand: the
 * address bar of a folder they are looking at (`/drive/folders/<id>`, and the
 * `/drive/u/0/folders/<id>` form that appears once a browser is signed in to
 * more than one Google account), the id on its own, and nothing at all — which
 * is My Drive, because a reader who wants their whole Drive has no link to
 * copy for it.
 *
 * The query is dropped (`?usp=sharing` rides on every link the *Share* button
 * produces) and so is anything after the id, so the trailing-slash and the
 * `/view` spellings both land on the same id.
 *
 * Everything else answers `null` rather than being passed through as an id. A
 * file link — `/file/d/<id>/view` — is the one that matters: it is not a folder,
 * the API would answer an empty listing rather than an error for it, and an
 * empty folder is the one refusal a reader cannot tell from a mistake of their
 * own. `sources/rewrite.ts` is where a *file* link belongs, and it already opens
 * one.
 */
export function folderIdFrom(pasted: string): string | null {
  const text = pasted.trim()
  if (text === '') return 'root'
  if (/^[A-Za-z0-9_-]+$/.test(text)) return text
  let url: URL
  try {
    url = new URL(text)
  } catch {
    return null
  }
  if (url.host.toLowerCase() !== 'drive.google.com') return null
  // `/folders/` rather than the whole path, so the `u/0` account segment and any
  // future one like it arrive working rather than as a fourth case to add here.
  const folder = /\/folders\/([^/?#]+)/.exec(url.pathname)
  if (folder !== null) return decodeURIComponent(folder[1])
  // Drive's own name for the top of an account, which is what the *My Drive*
  // link in its sidebar reads. Answered here so that pasting it does what it
  // plainly looks like it does, rather than refusing a link to the one folder
  // this box already offers by leaving the box empty.
  return /\/my-drive\/?$/.test(url.pathname) ? 'root' : null
}