diff --git a/SQL/migrate-2023/V017__persistence.sql b/SQL/migrate-2023/V017__persistence.sql new file mode 100644 index 00000000000..07ed8e1b88e --- /dev/null +++ b/SQL/migrate-2023/V017__persistence.sql @@ -0,0 +1,13 @@ +CREATE TABLE ss13_persistent_data ( + id INT NOT NULL AUTO_INCREMENT, + author_ckey VARCHAR(32) NULL, + type VARCHAR(128) NOT NULL, + created_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + content MEDIUMTEXT NOT NULL, + x INT NULL, + y INT NULL, + z INT NULL, + PRIMARY KEY (id), + INDEX idx_expireDate (expires_at) +); diff --git a/aurorastation.dme b/aurorastation.dme index d25f1ae67ec..14c60c5a817 100644 --- a/aurorastation.dme +++ b/aurorastation.dme @@ -112,6 +112,7 @@ #include "code\__DEFINES\outfit.dm" #include "code\__DEFINES\overmap.dm" #include "code\__DEFINES\path.dm" +#include "code\__DEFINES\persistence.dm" #include "code\__DEFINES\pipes.dm" #include "code\__DEFINES\prefs.dm" #include "code\__DEFINES\procpath.dm" @@ -267,6 +268,7 @@ #include "code\__HELPERS\logging\subsystems\law.dm" #include "code\__HELPERS\logging\subsystems\mapfinalization.dm" #include "code\__HELPERS\logging\subsystems\odyssey.dm" +#include "code\__HELPERS\logging\subsystems\persistence.dm" #include "code\__HELPERS\logging\subsystems\ZAS.dm" #include "code\__HELPERS\paths\jps.dm" #include "code\__HELPERS\paths\path.dm" @@ -372,6 +374,7 @@ #include "code\controllers\subsystems\overlays.dm" #include "code\controllers\subsystems\pai.dm" #include "code\controllers\subsystems\pathfinder.dm" +#include "code\controllers\subsystems\persistence.dm" #include "code\controllers\subsystems\ping.dm" #include "code\controllers\subsystems\plants.dm" #include "code\controllers\subsystems\profiler.dm" diff --git a/code/__DEFINES/global.dm b/code/__DEFINES/global.dm index 5b97201e264..157df823171 100644 --- a/code/__DEFINES/global.dm +++ b/code/__DEFINES/global.dm @@ -111,6 +111,10 @@ GLOBAL_VAR(custom_event_msg) GLOBAL_DATUM(dbcon, /DBConnection) GLOBAL_PROTECT(dbcon) +// Persistence subsystem track register - List of all persistent data tracks managed by the subsystem. +GLOBAL_LIST_EMPTY(persistence_register) +GLOBAL_PROTECT(persistence_register) + // Added for Xenoarchaeology, might be useful for other stuff. GLOBAL_LIST_INIT(alphabet_uppercase, list("A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z")) diff --git a/code/__DEFINES/persistence.dm b/code/__DEFINES/persistence.dm new file mode 100644 index 00000000000..90d6ea9eb3f --- /dev/null +++ b/code/__DEFINES/persistence.dm @@ -0,0 +1,6 @@ +/*############################################# + Constants for the persistence subsystem +#############################################*/ + +#define PERSISTENT_DEFAULT_EXPIRATION_DAYS 30 // Default expire timespan for newly created persistent objects +#define PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS 30 // Grace period for expired database entries before they get cleaned up. diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 765fed916ff..ed6bde979f2 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -227,7 +227,8 @@ #define INIT_ORDER_EARLY_ASSETS 48 #define INIT_ORDER_SPATIAL_GRID 43 #define INIT_ORDER_ECONOMY 40 -#define INIT_ORDER_MAPFINALIZE 31 //Asteroid generation, another aurora snowflake, must run before the atoms init +#define INIT_ORDER_MAPFINALIZE 32 // Asteroid generation, another aurora snowflake, must run before the atoms init +#define INIT_ORDER_PERSISTENCE 31 // Persistence subsystem, requires map load #define INIT_ORDER_ATOMS 30 #define INIT_ORDER_MACHINES 20 #define INIT_ORDER_DEFAULT 0 diff --git a/code/__HELPERS/logging/subsystems/persistence.dm b/code/__HELPERS/logging/subsystems/persistence.dm new file mode 100644 index 00000000000..76575df3200 --- /dev/null +++ b/code/__HELPERS/logging/subsystems/persistence.dm @@ -0,0 +1,7 @@ +/proc/log_subsystem_persistence(text) +#if defined(UNIT_TEST) + LOG_GITHUB_DEBUG("SSPersistence: [text]") +#else + if (GLOB.config?.logsettings["log_subsystems_persistence"]) + WRITE_LOG(GLOB.config.logfiles["world_subsystems_persistence_log"], "SSPersistence: [text]") +#endif diff --git a/code/controllers/configuration.dm b/code/controllers/configuration.dm index 01d74f89312..9decda48d81 100644 --- a/code/controllers/configuration.dm +++ b/code/controllers/configuration.dm @@ -62,6 +62,7 @@ GLOBAL_LIST_EMPTY(gamemode_cache) "log_subsystems_ghostroles" = TRUE, // Ghost Roles "log_subsystems_law" = TRUE, // Law "log_subsystems_cargo" = TRUE, // Cargo + "log_subsystems_persistence" = TRUE, // Persistence "log_subsystems_documents" = TRUE, // Documents "log_subsystems_fail2topic" = TRUE, // Fail2Topic "log_subsystems_mapfinalization" = TRUE, // Map Finalization @@ -120,6 +121,7 @@ GLOBAL_LIST_EMPTY(gamemode_cache) "world_subsystems_ghostroles_log" = "subsystems/ghostroles.log", "world_subsystems_law_log" = "subsystems/law.log", "world_subsystems_cargo_log" = "subsystems/cargo.log", + "world_subsystems_persistence_log" = "subsystems/persistence.log", "world_subsystems_documents_log" = "subsystems/documents.log", "world_subsystems_fail2topic_log" = "subsystems/fail2topic.log", "world_subsystems_mapfinalization_log" = "subsystems/mapfinalization.log", diff --git a/code/controllers/subsystems/persistence.dm b/code/controllers/subsystems/persistence.dm new file mode 100644 index 00000000000..35c86931742 --- /dev/null +++ b/code/controllers/subsystems/persistence.dm @@ -0,0 +1,310 @@ +SUBSYSTEM_DEF(persistence) + name = "Persistence" + init_order = INIT_ORDER_PERSISTENCE + flags = SS_NO_FIRE + +/*############################################# + Internal methods +#############################################*/ + +/** + * Initialization of the persistence subsystem. Initialization includes loading all persistent data and spawning the related objects. + */ +/datum/controller/subsystem/persistence/Initialize() + . = ..() + try + if(!GLOB.config.sql_enabled) + log_subsystem_persistence("SQL configuration not enabled! Persistence subsystem requires SQL.") + return SS_INIT_SUCCESS + + GLOB.persistence_register = list() + + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence subsystem init. Failed to connect.") + return SS_INIT_FAILURE + else + // Delete all persistent objects in the database that have expired and have passed the cleanup grace period (PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS) + database_clean_entries() + + // Retrieve all persistent data that is not expired + var/list/persistent_data = database_get_active_entries() + log_subsystem_persistence("Init: Retrieved [persistent_data.len] entries for instancing this round.") + + // Instantiate all remaining entries based of their type + // Assign persistence related vars found in /obj, apply content and add to live tracking list. + for (var/data in persistent_data) + CHECK_TICK + var/typepath = text2path(data["type"]) + if (!ispath(typepath)) // Type checking + continue + var/obj/instance = new typepath() + instance.persistence_track_id = data["id"] + track_apply_content(instance, data["content"], data["x"], data["y"], data["z"]) + register_track(instance, data["author_ckey"]) + + return SS_INIT_SUCCESS + catch(var/exception/e) + log_subsystem_persistence("Panic: Exception during subsystem initialize: [e]") + return SS_INIT_FAILURE + +/** + * Shutdown of the persistence subsystem. Adds new persistent objects, removes no longer existing persistent objects and updates changed persistent objects in the database. + */ +/datum/controller/subsystem/persistence/Shutdown() + try + if(!GLOB.config.sql_enabled) + log_subsystem_persistence("SQL configuration not enabled! Panic - Cannot save current round track changes!") + return + + // Subsystem shutdown: + // Create new persistent records for objects that have been created in the round + // Update tracked objects that have an ID (already existing from previous rounds) + // Delete persistent records that no longer exist in the registry (removed during the round) + + var/created = 0 + var/updated = 0 + var/expired = 0 + + // Get already stored data before saving new tracks so we can compare what has been updated or removed during the round. + var/list/existing_data = database_get_active_entries() + + for (var/obj/track in GLOB.persistence_register) + CHECK_TICK + if (track.persistence_track_id == 0) + // Tracked object has no ID meaning it is new, create a new persistent record for it + database_add_entry(track) + created++ + + // Find tracks that have been removed during the round by trying to find the track by database ID + // If we find the track, we need to check if it requires an update instead + for (var/record in existing_data) + var/found = FALSE + for (var/obj/track in GLOB.persistence_register) + CHECK_TICK + if (record["id"] == track.persistence_track_id) + // A track with the same ID has been found in the register, it still exists, check if we need to update it instead + found = TRUE // Prevent expiration of track + var/changed = FALSE + var/turf/T = get_turf(track) + if (T && T.x != record["x"]) + changed = TRUE + else if (T && T.y != record["y"]) + changed = TRUE + else if (T && T.z != record["z"]) + changed = TRUE + else if (track_get_content(track) != record["content"]) + changed = TRUE + if (changed) + database_update_entry(track) + updated++ + break // Track found (and perhaps updated), break off loop search as it won't need to be deleted anyways + if (!found) + // No track with the same ID has been found in the register, remove it from the database (expire) + database_expire_entry(record["id"]) + expired++ + + log_subsystem_persistence("Shutdown: Tried to create [created], update [updated] and expire [expired] tracks.") + catch(var/exception/e) + log_subsystem_persistence("Panic: Exception during subsystem shutdown: [e]") + return + +/** + * Generates StatEntry. Returns information about currently tracked objects. + */ +/datum/controller/subsystem/persistence/stat_entry(msg) + msg = ("Global register tracks: [GLOB.persistence_register.len]") + return ..() + +/** + * Safely get JSON persistent content of track. + * RETURN: JSON formatted content of track or null if an exception occured. + */ +/datum/controller/subsystem/persistence/proc/track_get_content(var/obj/track) + var/result = null + try + result = json_encode(track.persistence_get_content()) + catch(var/exception/e) + log_subsystem_persistence("Track: Failed to get/encode track content: [e]") + return result + +/** + * Safely apply persistent content to track. + * PARAMS: + * track = Object to apply content to. + * json = Custom persistent content JSON to be applied. + * x,y,z = x-y-z coordinates of object, can be null. + */ +/datum/controller/subsystem/persistence/proc/track_apply_content(var/obj/track, var/json, var/x, var/y, var/z) + try + track.persistence_apply_content(json_decode(json), x, y, z) + catch(var/exception/e) + log_subsystem_persistence("Track: Failed to apply/decode track content: [e]") + +/** + * Run cleanup on the persistence entries in the database. + * Cleanup includes all entries that have expired and have passed the clean up grace period (PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS). + */ +/datum/controller/subsystem/persistence/proc/database_clean_entries() + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_clean_entries. Failed to connect.") + else + var/datum/db_query/cleanup_query = SSdbcore.NewQuery( + "DELETE FROM ss13_persistent_data WHERE DATE_ADD(expires_at, INTERVAL :grace_period_days DAY) <= NOW()", + list("grace_period_days" = PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS) + ) + + cleanup_query.SetFailCallback(CALLBACK(PROC_REF(database_clean_entries_callback_failure))) + cleanup_query.SetSuccessCallback(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(qdel))) + cleanup_query.ExecuteNoSleep(TRUE) + +/datum/controller/subsystem/persistence/proc/database_clean_entries_callback_failure(var/datum/db_query/cleanup_query) + if (cleanup_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_clean_entries. " + cleanup_query.ErrorMsg()) + qdel(cleanup_query) + +/** + * Retrieve persistent data entries that haven't expired. + * RETURN: List of JSON, with ID, author_ckey, type, content, x, y, z + */ +/datum/controller/subsystem/persistence/proc/database_get_active_entries() + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_get_active_entries. Failed to connect.") + else + var/datum/db_query/get_query = SSdbcore.NewQuery( + "SELECT id, author_ckey, type, content, x, y, z FROM ss13_persistent_data WHERE NOW() < expires_at" + ) + get_query.Execute() + + var/list/results = list() + if (get_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_get_active_entries. " + get_query.ErrorMsg()) + return + else + while (get_query.NextRow()) + CHECK_TICK + var/list/entry = list( + "id" = get_query.item[1], + "author_ckey" = get_query.item[2], + "type" = get_query.item[3], + "content" = get_query.item[4], + "x" = get_query.item[5], + "y" = get_query.item[6], + "z" = get_query.item[7] + ) + results += list(entry) + qdel(get_query) + return results + +/** + * Adds a persistent data record to the database. + */ +/datum/controller/subsystem/persistence/proc/database_add_entry(var/obj/track) + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_add_entry. Failed to connect.") + else + var/content = track_get_content(track) + if (!content) + return + + var/turf/T = get_turf(track) + if(!T || !is_station_level(T.z)) // The persistence system only supports objects from the main map levels for multiple reasons, e.g. Z level value, mapping support + return + + var/datum/db_query/insert_query = SSdbcore.NewQuery( + "INSERT INTO ss13_persistent_data (author_ckey, type, created_at, expires_at, content, x, y, z) \ + VALUES (:author_ckey, :type, NOW(), DATE_ADD(NOW(), INTERVAL :expire_in_days DAY), :content, :x, :y, :z)", + list( + "author_ckey" = track.persistence_author_ckey, + "type" = "[track.type]", + "expire_in_days" = track.persistance_expiration_time_days, + "content" = content, + "x" = T.x, + "y" = T.y, + "z" = T.z + ) + ) + insert_query.Execute() + + if (insert_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_add_entry. " + insert_query.ErrorMsg()) + qdel(insert_query) + +/** + * Updates a persistent data record in the database. + */ +/datum/controller/subsystem/persistence/proc/database_update_entry(var/obj/track) + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_update_entry. Failed to connect.") + else + var/content = track_get_content(track) + if (!content) + return + + var/turf/T = get_turf(track) + if(!T || !is_station_level(T.z)) // The persistence system only supports objects from the main map levels for multiple reasons, e.g. Z level value, mapping support + return + + var/datum/db_query/update_query = SSdbcore.NewQuery( + "UPDATE ss13_persistent_data SET author_ckey=:author_ckey, expires_at=DATE_ADD(NOW(), INTERVAL :expire_in_days DAY), content=:content, x=:x, y=:y, z=:z WHERE id = :id", + list( + "author_ckey" = track.persistence_author_ckey, + "expire_in_days" = track.persistance_expiration_time_days, + "content" = content, + "x" = T.x, + "y" = T.y, + "z" = T.z, + "id" = track.persistence_track_id + ) + ) + update_query.Execute() + + if (update_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_update_entry. " + update_query.ErrorMsg()) + qdel(update_query) + +/** + * Expire a persistent data record in the database by setting it's expiration date to now. + */ +/datum/controller/subsystem/persistence/proc/database_expire_entry(var/track_id) + if(!SSdbcore.Connect()) + log_subsystem_persistence("SQL ERROR during persistence database_expire_entry. Failed to connect.") + else + var/datum/db_query/expire_query = SSdbcore.NewQuery( + "UPDATE ss13_persistent_data SET expires_at=NOW() WHERE id = :id", + list("id" = track_id) + ) + expire_query.Execute() + + if (expire_query.ErrorMsg()) + log_subsystem_persistence("SQL ERROR during persistence database_expire_entry. " + expire_query.ErrorMsg()) + qdel(expire_query) + +/*############################################# + Public methods +#############################################*/ + +/** + * Adds the given object to the list of tracked objects. At shutdown the tracked object will be either created or updated in the database. + * The ckey is an optional argument and is used for tracking user generated content by adding an author to the persistent data. + */ +/datum/controller/subsystem/persistence/proc/register_track(var/obj/new_track, var/ckey) + if(new_track.persistence_track_active) // Prevent multiple registers per object and removes the need to check the register if it's already in there + return + + var/turf/T = get_turf(new_track) + if(!T || !is_station_level(T.z)) // The persistence system only supports objects from the main map levels for multiple reasons, e.g. Z level value, mapping support + return + + new_track.persistence_track_active = TRUE + new_track.persistence_author_ckey = ckey + GLOB.persistence_register += new_track + +/** + * Removes the given object from the list of tracked objects. At shutdown the tracked object will be remove from the database. + */ +/datum/controller/subsystem/persistence/proc/deregister_track(var/obj/old_track) + if(!old_track.persistence_track_active) // Prevent multiple deregisters per object and removes the need to check the register if it's not in there + return + + old_track.persistence_track_active = FALSE + GLOB.persistence_register -= old_track diff --git a/code/game/objects/objs.dm b/code/game/objects/objs.dm index a3e282b78db..9fbd3aa26e7 100644 --- a/code/game/objects/objs.dm +++ b/code/game/objects/objs.dm @@ -65,7 +65,23 @@ var/list/req_one_access /* END ACCESS VARS */ + /* START PERSISTENCE VARS */ + // State check if the subsystem is tracking the object, used for easy state checking without iterating the register + var/persistence_track_active = FALSE + // Tracking ID of the object used by the persistence subsystem + var/persistence_track_id = 0 + // Author ckey of the object used in persistence subsystem + // Note: Not every type can have an author, like generated dirt for example + // Additionally, the ckey is only an indicator, for example: A player could pin a paper without having written it + // This should be considered for any moderation purpose + var/persistence_author_ckey = null + // Expiration time used when saving/updating a persistent type, this can be changed depending on the use case by assigning a new value + var/persistance_expiration_time_days = PERSISTENT_DEFAULT_EXPIRATION_DAYS + /* END PERSISTENCE VARS */ + /obj/Destroy() + if(persistence_track_active) // Prevent hard deletion of references in the persistence register by removing it preemptively + SSpersistence.deregister_track(src) STOP_PROCESSING(SSprocessing, src) unbuckle() QDEL_NULL(talking_atom) @@ -324,3 +340,25 @@ /// Override this to customize the effects an activated signaler has. /obj/proc/do_signaler() return + +/*############################################# + PERSISTENT +#############################################*/ + +/** + * Called by the persistence subsystem to retrieve relevant persistent information to be stored in the database. + * Expected to be overriden by derived objects. + * RETURN: Associated list with custom information (e.g.: ["test" = "abc", "counter" = 123]) + */ +/obj/proc/persistence_get_content() + return + +/** + * Called by the persistence subsystem to apply persistent data on the created object. + * Expected to be overriden by derived objects. + * PARAMS: + * content = Associated list with custom information (e.g.: ["test" = "abc", "counter" = 123]). + * x,y,z = x-y-z coordinates of object, can be null. + */ +/obj/proc/persistence_apply_content(content, x, y, z) + return diff --git a/code/game/objects/structures/noticeboard.dm b/code/game/objects/structures/noticeboard.dm index 860a35e205a..adcc021f87f 100644 --- a/code/game/objects/structures/noticeboard.dm +++ b/code/game/objects/structures/noticeboard.dm @@ -7,8 +7,12 @@ anchored = 1 var/notices = 0 -/obj/structure/noticeboard/Initialize() +/obj/structure/noticeboard/Initialize(mapload) + if (mapload) + add_papers_from_turf() . = ..() + +/obj/structure/noticeboard/proc/add_papers_from_turf() for(var/obj/item/I in loc) if(notices > 4) break if(istype(I, /obj/item/paper)) @@ -25,6 +29,7 @@ user.drop_from_inventory(attacking_item,src) notices++ icon_state = "nboard0[notices]" //update sprite + SSpersistence.register_track(attacking_item, ckey(usr.key)) // Add paper to persistent tracker to_chat(user, SPAN_NOTICE("You pin the paper to the noticeboard.")) else to_chat(user, SPAN_NOTICE("You reach to pin your paper to the board but hesitate. You are certain your paper will not be seen among the many others already attached.")) @@ -58,6 +63,7 @@ add_fingerprint(usr) notices-- icon_state = "nboard0[notices]" + SSpersistence.deregister_track(P) // Remove paper from persistent tracker if(href_list["write"]) if((usr.stat || usr.restrained())) //For when a player is handcuffed while they have the notice window open return diff --git a/code/modules/paperwork/paper.dm b/code/modules/paperwork/paper.dm index 962377a74a0..4af1e13fe35 100644 --- a/code/modules/paperwork/paper.dm +++ b/code/modules/paperwork/paper.dm @@ -791,6 +791,25 @@ . = ..() scan_target = WEAKREF(set_scan_target) +/*############################################# + PERSISTENT +#############################################*/ + +/obj/item/paper/persistence_get_content() + var/list/content = list() + content["title"] = name + content["text"] = info + return content + +/obj/item/paper/persistence_apply_content(content, x, y, z) + set_content(content["title"], content["text"]) + src.x = x + src.y = y + src.z = z + for(var/obj/object in loc) // Pin to noticeboard + if(istype(object, /obj/structure/noticeboard)) + var/obj/structure/noticeboard/notice_board = object + notice_board.add_papers_from_turf() /*############################################# diff --git a/config/example/logging.json b/config/example/logging.json index cbbd4bc4961..afa00af4801 100644 --- a/config/example/logging.json +++ b/config/example/logging.json @@ -33,6 +33,7 @@ "log_subsystems_ghostroles":1, "log_subsystems_law":1, "log_subsystems_cargo":1, + "log_subsystems_persistence":1, "log_subsystems_discord":0, "log_subsystems_documents":1, "log_subsystems_fail2topic":1, diff --git a/config/example/logging_files.json b/config/example/logging_files.json index b1e11087a22..49410734f0c 100644 --- a/config/example/logging_files.json +++ b/config/example/logging_files.json @@ -33,6 +33,7 @@ "world_subsystems_ghostroles_log":"subsystems/ghostroles.log", "world_subsystems_law_log":"subsystems/law.log", "world_subsystems_cargo_log":"subsystems/cargo.log", + "world_subsystems_persistence_log":"subsystems/persistence.log", "world_subsystems_discord_log":"subsystems/discord.log", "world_subsystems_documents_log":"subsystems/documents.log", "world_subsystems_fail2topic_log":"subsystems/fail2topic.log", diff --git a/html/changelogs/fabiank3-persistent-data-framework.yml b/html/changelogs/fabiank3-persistent-data-framework.yml new file mode 100644 index 00000000000..b76e4653ced --- /dev/null +++ b/html/changelogs/fabiank3-persistent-data-framework.yml @@ -0,0 +1,8 @@ +author: FabianK3 + +delete-after: True + +changes: + - rscadd: "Added subsystem for persistent data between rounds. The framework includes the subsystem responsible for managing persistent data and provides functionality for objects to be added to said system. See PR and GitHub Wiki documentation for more technical details." + - config: "Added new log type for the persistence subsystem." + - experiment: "Added papers and noticeboards to the persistence subsystem. Papers and their contents (title and text) will now be persistent across rounds when they get pinned to noticeboards. They will loose persistence when they either get unpinned during a round or after no changes within 30 days. Unpinning and re-pinning will keep them persistent but not update the 30 days expiration date; Unpinning, editing and re-pinning them will reset the 30 days expiration date."