@@ -38,16 +38,21 @@ public struct NoteArchive {
3838 /// Mapping of page UUID (constant across revisions) to the current page properties digest
3939 private var pagePropertyDigests : [ String : String ] = [ : ]
4040
41+ /// A mapping of page UUID to page contents loaded from the archive.
42+ private var pageContentsCache : [ String : PageContents ] = [ : ]
43+
4144 /// Returns the current mapping of page id to page properties
4245 public var pageProperties : [ String : PageProperties ] {
43- return pagePropertyDigests. compactMapValues { propertyDigest -> PageProperties ? in
46+ let archiveVersion = pagePropertyDigests. compactMapValues { propertyDigest -> PageProperties ? in
4447 guard
4548 let snippet = archive. snippets [ propertyDigest] ,
4649 let properties = try ? PageProperties ( snippet) else {
4750 return nil
4851 }
4952 return properties
5053 }
54+ let cacheVersion = pageContentsCache. compactMapValues { $0. pageProperties }
55+ return archiveVersion. merging ( cacheVersion, uniquingKeysWith: { _, new in new } )
5156 }
5257
5358 public enum SerializationError : Error {
@@ -84,6 +89,7 @@ public struct NoteArchive {
8489
8590 /// Text version of the archive, suitable for storing to disk.
8691 public func textSerialized( ) -> String {
92+ assert ( pageContentsCache. allSatisfy ( { !$0. value. dirty } ) )
8793 return archive. textSerialized ( )
8894 }
8995
@@ -93,27 +99,27 @@ public struct NoteArchive {
9399 @discardableResult
94100 public mutating func insertNote(
95101 _ text: String ,
96- contentChangeTime timestamp: Date ,
97- versionTimestamp: Date
102+ contentChangeTime timestamp: Date
98103 ) throws -> String {
99- let ( propertiesSnippet, _) = try archivePageProperties ( from: text, timestamp: timestamp)
104+ var pageContents = PageContents ( )
105+ pageContents. setText ( text, modifiedTimestamp: timestamp)
100106 let key = UUID ( ) . uuidString
101- pagePropertyDigests [ key] = propertiesSnippet. sha1Digest
102- try archivePageManifestVersion ( timestamp: versionTimestamp)
107+ pageContentsCache [ key] = pageContents
103108 return key
104109 }
105110
106111 /// Removes a note from the archive.
107112 /// - throws: `RetrievalError.noSuchPage` if the page does not exist.
108- public mutating func removeNote( for pageIdentifier: String , versionTimestamp: Date ) throws {
109- guard pagePropertyDigests. removeValue ( forKey: pageIdentifier) != nil else {
110- throw RetrievalError . noSuchPage ( pageIdentifier)
111- }
112- try archivePageManifestVersion ( timestamp: versionTimestamp)
113+ public mutating func removeNote( for pageIdentifier: String ) {
114+ pageContentsCache. removeValue ( forKey: pageIdentifier)
115+ pagePropertyDigests. removeValue ( forKey: pageIdentifier)
113116 }
114117
115118 /// Gets the current version of the text for a particular page.
116119 public func currentText( for pageIdentifier: String ) throws -> String {
120+ if let cacheEntry = pageContentsCache [ pageIdentifier] {
121+ return cacheEntry. text
122+ }
117123 let properties = try currentPageProperties ( for: pageIdentifier) . properties
118124 guard let noteSnippet = archive. snippets [ properties. sha1Digest] else {
119125 throw RetrievalError . noSuchText ( properties. sha1Digest)
@@ -141,36 +147,55 @@ public struct NoteArchive {
141147 /// - parameter pageIdentifier: The page identifier to update
142148 /// - parameter text: The new text of the page
143149 /// - parameter contentChangeTime: The *content change* timestamp of the text
144- /// - parameter versionTimestamp: The time we are applying this change. If nil, the version
145- /// will be recorded with `timestamp`. Importing an existing file is the expected
146- /// case where the version timestamp is different from the content change timestamp.
147150 /// - note: If `text` is not different from the current value associated with `pageIdentifier`,
148151 /// this operation is a no-op. No new version gets created.
149152 public mutating func updateText(
150153 for pageIdentifier: String ,
151154 to text: String ,
152- contentChangeTime timestamp: Date ,
153- versionTimestamp: Date
154- ) throws {
155- let ( existingSnippet, existingProperties) = try currentPageProperties ( for: pageIdentifier)
156- let ( newSnippet, newProperties) = try archivePageProperties ( from: text, timestamp: timestamp)
157- // New content is the same as the old content
158- if newProperties. sha1Digest == existingProperties. sha1Digest {
155+ contentChangeTime timestamp: Date
156+ ) {
157+ if pageContentsCache [ pageIdentifier] != nil {
158+ pageContentsCache [ pageIdentifier] !. setText ( text, modifiedTimestamp: timestamp)
159159 return
160+ } else {
161+ var contents = PageContents ( )
162+ contents. setText ( text, modifiedTimestamp: timestamp)
163+ pageContentsCache [ pageIdentifier] = contents
160164 }
161- existingSnippet. encodeAsDiff ( from: newSnippet)
162- guard let existingTextSnippet = archive. snippets [ existingProperties. sha1Digest] else {
163- throw RetrievalError . noSuchPage ( existingProperties. sha1Digest)
165+ }
166+
167+ /// Updates all page properties that are stale in the contents cache.
168+ /// - returns: How many page properties were updated.
169+ @discardableResult
170+ public mutating func batchUpdatePageProperties( ) -> Int {
171+ let updated = archive. updatePageProperties (
172+ in: pageContentsCache. filter ( { $0. value. pagePropertiesStale } ) ,
173+ parsingRules: parsingRules
174+ )
175+ pageContentsCache. merge ( updated, uniquingKeysWith: { _, new in new } )
176+ return updated. count
177+ }
178+
179+ /// Creates a new Version representing the current page manifest in the archive.
180+ /// - parameter timestamp: The version timestamp.
181+ /// - throws: Any errors creating the symbolic reference
182+ public mutating func archivePageManifestVersion( timestamp: Date ) throws {
183+ try flushContentsCache ( )
184+ let version = Version ( timestamp: timestamp, digest: archivePageManifest ( ) )
185+ if let existingVersion = pagePropertiesVersionHistory. last,
186+ existingVersion. digest == version. digest {
187+ // The new version is identical to the old version -- no-op.
188+ return
164189 }
165- guard let newTextSnippet = archive. snippets [ newProperties. sha1Digest] else {
166- throw RetrievalError . noSuchPage ( newProperties. sha1Digest)
190+ if let existingVersion = pagePropertiesVersionHistory. last,
191+ let oldManifestSnippet = archive. snippets [ existingVersion. digest] ,
192+ let newManifestSnippet = archive. snippets [ version. digest] {
193+ newManifestSnippet. encodeAsDiff ( from: nil )
194+ oldManifestSnippet. encodeAsDiff ( from: newManifestSnippet)
167195 }
168- newTextSnippet. encodeAsDiff ( from: nil )
169- existingTextSnippet. encodeAsDiff ( from: newTextSnippet)
170- pagePropertyDigests [ pageIdentifier] = newSnippet. sha1Digest
171- try archivePageManifestVersion ( timestamp: versionTimestamp)
196+ pagePropertiesVersionHistory. append ( version)
197+ try archiveVersionHistory ( )
172198 }
173-
174199}
175200
176201// MARK: - Import
@@ -188,15 +213,13 @@ public extension NoteArchive {
188213 try updateText (
189214 for: importRecord. pageIdentifier,
190215 to: text,
191- contentChangeTime: contentChangeDate,
192- versionTimestamp: importDate
216+ contentChangeTime: contentChangeDate
193217 )
194218 }
195219 } else {
196220 let pageIdentifier = try insertNote (
197221 text,
198- contentChangeTime: contentChangeDate,
199- versionTimestamp: importDate
222+ contentChangeTime: contentChangeDate
200223 )
201224 importRecords [ fileName] = FileImportRecord (
202225 pageIdentifier: pageIdentifier,
@@ -235,6 +258,39 @@ private extension NoteArchive {
235258 }
236259 }
237260
261+ /// An in-memory cache record of the contents of a page
262+ struct PageContents {
263+ var text : String
264+ var modifiedTimestamp : Date
265+ var dirty : Bool
266+ var pageProperties : PageProperties ?
267+ var pagePropertiesStale : Bool
268+
269+ init ( ) {
270+ self . text = " "
271+ self . modifiedTimestamp = Date . distantPast
272+ self . dirty = false
273+ self . pageProperties = nil
274+ self . pagePropertiesStale = false
275+ }
276+
277+ init ( text: String , pageProperties: PageProperties ) {
278+ self . text = text
279+ self . dirty = false
280+ self . pageProperties = pageProperties
281+ self . pagePropertiesStale = false
282+ self . modifiedTimestamp = pageProperties. timestamp
283+ }
284+
285+ /// Updates text.
286+ mutating func setText( _ text: String , modifiedTimestamp: Date ) {
287+ self . text = text
288+ self . modifiedTimestamp = modifiedTimestamp
289+ self . dirty = true
290+ self . pagePropertiesStale = true
291+ }
292+ }
293+
238294 /// Represents a specific file that has been imported into the archive.
239295 struct FileImportRecord : Codable {
240296 /// The UUID representing the page that holds the file contents.
@@ -257,6 +313,53 @@ private extension NoteArchive {
257313 try archive. setSymbolicReference ( key: " file-import " , text: encoded)
258314 }
259315
316+ /// Writes any dirty content from `pageContentsCache` to `archive`
317+ /// - returns: How many modified pages were updated
318+ @discardableResult
319+ mutating func flushContentsCache( ) throws -> Int {
320+ var modifiedPageCount = 0
321+ // Make sure all properties are up to date
322+ batchUpdatePageProperties ( )
323+ for (pageIdentifier, contents) in pageContentsCache where contents. dirty {
324+ let newTextSnippet = archive. insert ( contents. text)
325+ // Because we updated all page properties, safe to force-unwrap
326+ let newPropertiesSnippet = archive. insert ( try contents. pageProperties!. makeSnippet ( ) )
327+ pageContentsCache [ pageIdentifier] ? . dirty = false
328+ modifiedPageCount += 1
329+
330+ // If there was already content for this page in the archive, delta-encode it.
331+ guard let ( existingPropertiesSnippet, existingProperties) = try ? currentPageProperties ( for: pageIdentifier) else {
332+ pagePropertyDigests [ pageIdentifier] = newPropertiesSnippet. sha1Digest
333+ continue
334+ }
335+ // New content is the same as the old content
336+ if newPropertiesSnippet. sha1Digest == existingPropertiesSnippet. sha1Digest {
337+ continue
338+ }
339+ newPropertiesSnippet. encodeAsDiff ( from: nil )
340+ existingPropertiesSnippet. encodeAsDiff ( from: newPropertiesSnippet)
341+ guard let existingTextSnippet = archive. snippets [ existingProperties. sha1Digest] else {
342+ throw RetrievalError . noSuchPage ( existingProperties. sha1Digest)
343+ }
344+ // Note the content can be the same but the properties can have different timestamps
345+ // So, check and make sure we didn't wind up with identical content before delta encoding.
346+ if newTextSnippet. sha1Digest != existingTextSnippet. sha1Digest {
347+ newTextSnippet. encodeAsDiff ( from: nil )
348+ existingTextSnippet. encodeAsDiff ( from: newTextSnippet)
349+ }
350+ pagePropertyDigests [ pageIdentifier] = newPropertiesSnippet. sha1Digest
351+ }
352+ return modifiedPageCount
353+ }
354+
355+ /// Gets the page properties for a page identifier.
356+ ///
357+ /// - note: We return both the snippet and the decoded properties so we have the option of adding delta encoding to the snippet
358+ /// if we are updating the contents of the page.
359+ ///
360+ /// - parameter pageIdentifier: the page to retrieve properties for
361+ /// - returns: A tuple containing the TextSnippet of serialized properties and the deserialized version of the properties
362+ /// - throws: `RetrievalError.noSuchPage` if the page was not found in the archive.
260363 func currentPageProperties(
261364 for pageIdentifier: String
262365 ) throws -> ( snippet: TextSnippet , properties: PageProperties ) {
@@ -267,21 +370,6 @@ private extension NoteArchive {
267370 return ( propertiesSnippet, try PageProperties ( propertiesSnippet) )
268371 }
269372
270- /// Creates a new Version representing the current page manifest in the archive.
271- /// - parameter timestamp: The version timestamp.
272- /// - throws:
273- mutating func archivePageManifestVersion( timestamp: Date ) throws {
274- let version = Version ( timestamp: timestamp, digest: archivePageManifest ( ) )
275- if let existingVersion = pagePropertiesVersionHistory. last,
276- let oldManifestSnippet = archive. snippets [ existingVersion. digest] ,
277- let newManifestSnippet = archive. snippets [ version. digest] {
278- newManifestSnippet. encodeAsDiff ( from: nil )
279- oldManifestSnippet. encodeAsDiff ( from: newManifestSnippet)
280- }
281- pagePropertiesVersionHistory. append ( version)
282- try archiveVersionHistory ( )
283- }
284-
285373 /// Writes the version history array into the archive.
286374 /// - note: We keep only one copy of the version array in the archive
287375 /// - throws: `TextSnippetArchive.Error` if there is a problem creating the symbolic reference to the version snippet
@@ -308,6 +396,8 @@ private extension NoteArchive {
308396 . compactMap ( Version . init)
309397 }
310398
399+ /// Writes the current `pagePropertyDigests` into the archive.
400+ /// - returns: The sha1Digest of the snippet created to hold this version of the manifest.
311401 mutating func archivePageManifest( ) -> String {
312402 let manifest = pagePropertyDigests
313403 . map { " \( $0. key) \( $0. value) " }
@@ -317,6 +407,11 @@ private extension NoteArchive {
317407 return manifestSnippet. sha1Digest
318408 }
319409
410+ /// Loads a specific version of the page manifest from the archive.
411+ /// - parameter archive: The archive to load from.
412+ /// - parameter manifestIdentifier: The sha1Digest of a specific version of a manifest.
413+ /// - returns: a dictionary mapping pageIdentifiers to sha1Digests of specific versions of pages.
414+ /// - throws: `RetrievalError.noSuchPage` if the manifest is not in the archive.
320415 static func getPageManifest(
321416 from archive: TextSnippetArchive ,
322417 manifestIdentifier: String
@@ -355,6 +450,32 @@ private extension NoteArchive {
355450 }
356451}
357452
453+ private extension TextSnippetArchive {
454+ /// Given an array of pageContents, computes updated PageProperties for any that are stale.
455+ /// - note: This is mutating because we have to update any challenge templates in the archive
456+ /// - returns: An array where every entry has non-stale properties.
457+ mutating func updatePageProperties(
458+ in pageContents: [ String : NoteArchive . PageContents ] ,
459+ parsingRules: ParsingRules
460+ ) -> [ String : NoteArchive . PageContents ] {
461+ pageContents. mapValues { pageContent in
462+ guard pageContent. pagePropertiesStale else { return pageContent }
463+ var pageContent = pageContent
464+ let nodes = parsingRules. parse ( pageContent. text)
465+ let challengeTemplateKeys = nodes. archiveChallengeTemplates ( to: & self )
466+ pageContent. pageProperties = PageProperties (
467+ sha1Digest: TextSnippet ( pageContent. text) . sha1Digest,
468+ timestamp: pageContent. modifiedTimestamp,
469+ hashtags: nodes. hashtags,
470+ title: String ( nodes. title. split ( separator: " \n " ) . first ?? " " ) ,
471+ cardTemplates: challengeTemplateKeys. map { $0. description }
472+ )
473+ pageContent. pagePropertiesStale = false
474+ return pageContent
475+ }
476+ }
477+ }
478+
358479private extension Date {
359480 /// True if the receiver and `other` are "close enough"
360481 func closeEnough( to other: Date ) -> Bool {
0 commit comments